-
토비의 스프링[5] 서비스 추상화Spring 2021. 2. 25. 04:41
서론
여태 제작한 DAO에 트랜잭션을 적용해보며 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 일관된 방법으로 사용할 수 있도록 지원하는지 알아보자.
사용자 레벨 관리 기능 추가
지금까지 제작한 UserDao는 User오브젝트에 대한 CRUD를 제외하면 아무 비즈니스 로직도 가지고 있지 않다. 그렇기에 여기에 사용자 활동내역에 따른 레벨을 조정해주는 기능과 사용자 활동내역을 알 수 있도록 몇개의 필드를 추가해보자.
package com.example.toby.초난감DAO.user; import lombok.*; @Getter @Setter @ToString @NoArgsConstructor @AllArgsConstructor public class User { private String id; // 아이디 private String name; // 이름 private String pwd; // 비밀번호 private Level level; // 등급 private int login; // 로그인 횟수 50회 이상이면 실버로 승급 private int recommend; // 추천수 실버에서 30회 이상이면 골드로 승급 private String email; // 이메일 public enum Level { BASIC(1), SILVER(2), GOLD(3); private final int value; Level(int value) { this.value = value; } public int intValue() { return this.value; } 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 + "'"); } } } }
수정된 필드에 맞춰 모든 테스트들이 성공하게끔 테스트 픽스처와 UserDao와 UserDaoJdbc를 수정해주면 된다.
추가로 업데이트 쿼리는 Where 절이 있든 or 없든 쿼리가 정상적으로 나가게 된다. 그렇기에 테스트코드를 통해 Where 절이 정상적으로 나가서 User가 업데이트 되었는지 아래 방법중 하나로 확인해보자.
1. JdbcTemplate의 update()가 반환해주는 값을 통하여 테이블의 내용에 영향을 주는 SQL을 통하여 영향받은 로우를 확인해볼 수 있다.
2. 테스트 코드를 보완하여 원하는 user외 정보가 변했는지 확인할 수 있다.
매번 아무 의심없이 확신하는것 보다는 테스트 코드를 작성하는 책 방식이 진짜 좋은거 같다.
@Test @DisplayName("update 예제") public void update() { dao.deleteAll(); dao.add(user1); dao.add(user2); user1.setName("인우전"); user1.setPwd("인우전1@#"); user1.setLevel(User.Level.GOLD); user1.setLogin(1000); user1.setRecommend(999); dao.update(user1); User user1update = dao.get(user1.getId()); User user2same = dao.get(user2.getId()); checkSameUser(user1, user1update); checkSameUser(user2, user2same); }
UserService.upgradeLevlels()
사용자 관리 로직은 어디에 두면 좋을까?
UserDaoJdbc는 적당하지 않다. DAO는 데이터를 가져오고 조작을 다루는 곳이다.
사용자 관리 로직은 해당 비지니스 로직을 담을 클래스를 추가하여 다뤄보자.
public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } } ... @Bean public UserService userService() { return new UserService(userDao()); }
upgradeLevels() 메소드
... public void upgradeLevles() { List<User> users = userDao.getAll(); for(User user : users) { Boolean changed = null; if(user.getLevel() == Level.GOLD) { changed = false; } else if(user.getLevel() == Level.BASIC && user.getLogin() >= 50) { user.setLevel(Level.SILBER); changed = true; } else if(user.getLevel() == Level.SILVER && user.getRecommend() >= 30) { user.setLevel(Level.SILBER); changed = true; } if(changed) userDao.update(user); } }
UserService.add()
사용자 관리 비지니스 로직에서 대부분은 구현했지만 아직 회원가입 시 기본 레벨이 BASIC 레벨이어야 하는 부분이 구현되지 않았다.
이는 UserDaoJdbc 주어진 User 오브젝트를 DB에 정보를 넣고 읽는 방법에만 관심을 가져야 하기에 적합하지 않는다. 그렇다고 최초 level 필드를 BASIC으로 초기화 하는것은 처음 가입할 때를 제외하면 무의미한 정보이기에 부적합하다. UserDaoJdbc add() 메소드가 DB에 User 오브젝트를 넣어주는 역활을 충실히 한다면, UserService에도 add()를 만들어두고 최초 생성 시 레벨이 BASIC으로 설정되는 비지니스 로직을 담담하게 하면 된다.
코드개선
비지니스 로직의 기본 구현을 모두 마쳤다. 테스트도 만들어서 검증했을때 문제가 없을경우 다음 단계로 넘어가도 되지만 작성된 코드를 살펴보며 다음과 같은 질문을 해볼 필요가 있다.
질문
1. 코드의 중복은 없는가?
2. 코드가 무엇을 하는것인지 이해하기 불편함은 없는가?
3. 코드가 자신이 있어야 할 자리에 있는가?
4. 앞으로 변경이 일어난다면 어떤 것이 있을것이고, 그 변화에 쉽게 대응할 수 있도록 작성 되었는가?
대답
1. 새로운 레벨이 추가된다면 레벨 갯수만큼 반복되는 if 반복문이 증가하면서 점점 지저분한 코드가 될 것이다.
2. 레벨과 업그레이드 조건을 동시에 비교하는 부분도 서로 다른 성격을 한곳에 넣었기에 문제가 될 수 있다.
upgradeLevels() 리팩토링
추상적인 레벨에서 로직을 작성해보자. 기존 메소드는 자주 변경될 가능성이 있는 구체적인 내용이 추상적인 로직의 흐름과 함께 섞여있다.
// 기본 작업 흐름만 남겨준 upgradeLevels() public void upgradeLevels() { List<User> users = userDao.getAll(); // 모든 사용자 정보를 가져온 뒤 for(User user : users) { if(canUpgradeLevel(user)) { // 한명씩 업그레이드가 가능한지 확인 후 upgradeLevel(user); // 업그레이드를 진행한다. } } }
위 코드는 이렇게 읽을 수 있다.
1. 모든 사용자 정보를 가져온 뒤
2. 한명씩 레벨을 업그레이드 할 수 있는지 확인한 뒤
3. 가능하면 업그레이드를 진행한다.
canUpgradeLevel() 메소드도 마찬가지로 주어진 user에 대해 업그레이드가 가능하면 true, 불가능 하면 false를 리턴해준다. 상태에 따라서 업그레이드 조건만 비교하면 되므로, 역활과 책임이 명료해진다.
private boolean canUpgradeLevel(User user) { User.Level currentLevel = user.getLevel(); switch (currentLevel) { case BASIC: return (user.getLogin() >= MIN_LOGCOUNT_FOR_SILVER); case SILVER: return (user.getRecommend() >= MIN_RECOMMEND_FOR_GOLD); case GOLD: return false; default: throw new IllegalArgumentException("Unknown Level : '" + currentLevel + "'"); } }
업그레이드를 확인하는 방법은 User 오브젝트에서 레벨을 가져와 case 문을 통하여 각 레벨에 대한 조건이 만족하는지 확인해주며, 로직에서 처리할 수 없는 레벨은 예외를 던져준다. (새로운 레벨이 추가되어도 업그레이드 로직을 추가하지 않았더라면 예외를 던진다.)
upgradeLevel() 메소드는 사용자의 레벨을 다음 단계로 바꿔주는 것과 변경사항을 DB에 업데이트 해주는 것이다. (추후 사용자에게 안래를 해준다던지... 다른 로직이 추가될 수 있다.) 업그레이드 작업용 메소드를 따로 분리하면 나중에 작업내용이 추가되더라도 어느 곳을 수정해야할지 명확해진다는 장점이 있다.
private void upgradeUser(User user) { if (user.getLevel() == Level.BASIC) { user.setLevel(Level.SILVER); } else if (user.getLevel() == Level.SILVER) { user.setLevel(Level.GOLD); } userDao.update(user); }
정상적으로 동작하도록 리팩토링을 했지만 이 메소드는 맘에 들지 않는다.
1. 다음 단계가 무엇인가 하는 로직과 그떄 사용자 오브젝트의 레벨 필드를 변경해준다는 로직이 함께 있다.
2. 예외상황에 대한 처리가 존재하지 않는다.
3. 레벨이 증가할때마다 늘어나는 if문
이러한 내용도 분리하며 레벨의 순서 및 다음 레벨이 무엇인지를 결정하는 일을 Level에게 맡기자. (굳이 레벨의 순서를 UserService에 담아둘 필요가 없다.)
public enum Level { GOLD(3, null), SILVER(2, Level.GOLD), BASIC(1, Level.SILVER); private final int value; private final Level next; Level(int value, Level next) { this.value = value; this.next = next; } public int intValue() { return this.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 + "'"); } } }
1. Level enum에 next라는 다음 단계 레벨 정보를 담을 수 있도록 필드를 추가한다.
2. 생성자 파라미터를 추가하여 다음 단계 레벨 정보를 지정할 수 있도록 해준다.
3. 레벨을 정의할때 DB에 저장될 값과 다음 레벨이 무엇인지를 함께 넣어준다.
1. 그 값이 궁금하면 nextLevel()을 호출하여 확인할 수 있다.
2. 일일히 if문을 통한 다음 레벨이 무엇인지 확인하는 로직을 UserService에 담아둘 필요가 없어진다.
사용자 정보가 변경되는 부분을 UserService에서 User로 옮겨보자
1. User의 내부 정보가 변경되는 것은 UserService보다는 User가 스스로 다루는 게 적절하다.
2. User에게 레벨 업그레이드를 해야하니 정보를 변경하라고 요청하는 편이 더욱 더 적절하다.
... public void upgradeLevel() { Level nextLevel = this.level.nextLevel(); if(nextLevel == null) { throw new IllegalArgumentException(this.level + "은 업그레이드가 불가능합니다."); } else { this.level = nextLevel; } }
UserServiceTest 개선
public static final int MIN_LOGCOUNT_FOR_SILVER = 50; public static final int MIN_RECOMMEND_FOR_GOLD = 30; ... private void checkLevelUpgraded(User user, boolean upgraded) { User userUpdate = userDao.get(user.getId()); if(upgraded) { Assertions.assertEquals(userUpdate.getLevel(), user.getLevel().nextLevel()); } else { Assertions.assertEquals(userUpdate.getLevel(), user.getLevel()); } }
어떤 레벨로 바뀔 것이인가가 아닌 다음 레벨로 업그레이드될 것인가를 지정하여 검증하는 메소드를 제작하여 업그레이드 테스트를 진행할 수있게 변경하자.
추가로 로그인, 추천횟수의 경우 상수로 선언하여 변경에 대한 유연성을 제공하자.
트랜잭션 서비스 추상화
만약 레벨 업그레이드 도중 에러가 발생했더라면 어떻게 될까?
1. 변경된 사용자를 그대로 냅둘까?
2. 전부다 롤백 시킬까?
실제로 예외를 발생시켜 이를 확인해 볼 수 있다. 하지만 예외의 경우 1초도 안걸리는 시간안에 DB를 다운시키거나 네트워크를 다운시키거나 하는 순발력 테스트는 좋은 생각이 아니다.
그렇기에 의도적으로 예외를 만다는게 나을 것이다.
테스트용 UserService 대역
작업 중간에 강제로 예외가 발생하도록 애플리케이션 코드를 수정하면 된다. 하지만 테스트를 위하여 코드를 함부로 수정하는 것 보다는 대역을 사용하는 방법이 좋다.
UserService를 상속받아 테스트에 필요한 기능을 추가하도록 일부 메소드를 오버라이딩 하는 방법이 나을 것 같다.
먼저 upgradeLevel의 접근권한을 protected로 수정하여 상속을 통해 오버라이딩이 가능하게 하자.
static class TestUserService extends UserService { private UserDao userDao; private String id; public TestUserService(UserDao userDao, String id) { // 생성자 주입 super(userDao); 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 { // 테스트용 예외 } ... @Test @DisplayName("예외 발생 시 작업 취소 여부 테스트") public void upgradeAllOrNothing() throws Exception { userDao.deleteAll(); UserService testUserService = new TestUserService(this.userDao, users.get(3).getId()); for(User user : users) userDao.add(user); try { testUserService.upgradeLevels(); Assertions.fail("TestUserServiceException expected"); } catch (TestUserServiceException e) { } checkLevelUpgraded(users.get(1), false); }
TestUserService에 userDao를 수동 DI 해준 뒤 테스트를 진행을 하여 예외가 발생할 경우 업그레이드된 레벨들이 롤백될거라고 기대를 하였지만 그대로 유지가 되어 테스트가 실패되었다.
이는 메소드의 작업 단위인 트랜잭션이 upgradeLevels() 메소드에 적용되지 않았기 때문에 새로 추가된 기술 요건을 만족하지 못하고, 이를 검증하기 위해 만든 테스트가 실패하는 것이다.
트랜잭션 경계설정
DB는 그 자체로 완벽한 트랜잭션을 지원한다. 단일 SQL의 경우 DB가 트랜잭션을 보장해준다고 신뢰할 수 있다. 하지만 다중 SQL이 하나의 트랜잭션으로 취급될 수도 있다. 이 경우 n번째 SQL이 성공하더라도 n+1 SQL이 실패할 경우 앞에서 처리한 SQL 작업도 취소시켜야 한다. 이런 취소 작업을 트랜잭션 롤백이라고 하며, 반대의 경우 DB에 알려주어 확정시키는 트랜잭션 커밋이 이루어진다.
JDBC 트랜잭션의 트랜잭션 경계설정
모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지 이지만 끝나는 방법은 두 가지이다.
1. 모든 작업을 무효화 하는 롤백
2. 모든 작업을 반영하는 커밋
이 처럼 애플리케이션 내에서 트랜잭션이 시작되 끝나는 위치를 트랜잭션 경계라고 부른다.
JDBC의 트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에 일어난다. 이 과정에서 setAutoCommit(false)로 틀내잭션의 시작을 선언하고 commit() or rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정이라고 한다.
이렇게 하나의 Connection이 만들어지고 닫히는 범위 안에서 존재하며 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션이라 한다.
UserService와 UserDao의 트랜잭션 문제
JdbcTemplate의 메소드를 사용하는 UserDao는 각 메소드마다 독립적인 커넥션을 만들어 사용하기에 독립적인 트랜잭션이 실행된다.
데이터 엑세시 코드를 DAO로 만들어서 분리해놓은 경우 DAO를 호출할 때마다 새로운 트랜잭션이 만들어지게 된다. (DAO에서 메소드 실행 시 DB커넥션을 매번 만들기 때문)
비지니스 로직 내의 트랜잭션 경계설정
이 문제를 해결하기 위해 DAO 메소드 안으로 upgradeLevels() 메소드의 내용을 옮겨 사용해야 하지만 그동안 제작한 성격과 책임이 다른 코드들을 분리한 작업을 다시 한가운데로 모아 비지니스 로직과 데이터 로직을 한가운데 묶는 결과를 초례한다.
결국 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다. 프로그램의 흐름을 볼 때 upgradeLevels() 메소드의 시작과 함께 트랜잭션이 시작되고 메소드를 빠져나올때 종료돼야 하기 때문이다.
하지만 그렇게 되기 위해서는 upgradeLevels() 메소드의 트랜잭션 경계구조는 이렇게 설정 되어야 한다.
public void upgradeLevels() throws Exception { 1. DB Connection 생성 2. 트랜잭션 시작 try ( 3. DAO 메소드 호출 4. 트랜잭션 커밋 catch(Exception e) { 5. 트랜잭션 롤백 throw e; finally { 6. DB Connection 종료 } }
트랜잭션 때문에 DB 커넥션과 트랜잭션 코드를 UserService로 가져왔지만 UserDao update() 메소드는 반드시 upgradeLevelse() 메소드에서 만든 Connection을 사용해야 한다. (그래야 같은 트랜잭션에서 동작) 그렇기에 여기서 제작한 Connection 오브젝트를 UserDao에게 파라미터로 전달해주어야 하는데 이럴 경우 또다시 문제가 발생한다.
- UserService에 try/catch/finally 코드로 뒤범벅 될 것이다.
- UserService의 메소드에 Connection 파라미터가 추가돼야 한다.
- upgradeLevels() 에서 사용하는 메소드의 어딘가에서 DAO를 필요로 한다면, 그 사이의 모든 메소드에 걸쳐서 Connection 오브젝트가 전달되어야 하는데 UserService의 경우 싱글톤 빈이기에 저장하여 다른 메서드에서 사용할 수 없다.
- 멀티스레드 환경에서는 공유하는 인스턴스 변수에 스레드별로 생성하는 정보를 저장하다가는 서로 덮어쓸 위험이 있기에 UserService 메소드는 Connection 파라미터로 지저분해질 것이다.
- Connection 파라미터가 UserDao 인터페이스 메소드에 추가된다면 UserDao는 더이상 데이터 엑세스 기술에 독립적일 수 없다.
- JDBC면 Connection JPA나 하이버네이트면 EntityManager 혹은 Session이 전달되기에 결국 변화에 따른 UserService 코드도 같이 수정되어 DAO를 분리하여 DI를 적용한게 물거품이 된다.
트랜잭션 동기화
UserService 메소드 안에서 트랜잭션의 경계를 설정해 관리하려던 깔끔한 코드를 포기할지?? 아니면 트랜잭션 기능을 포기해야 할까???
Connection 파라미터 제거
먼저 Connection을 파라미터로 직접 전달하는 문제를 해결해 보자.
upgradeLevels() 메소드가 트랜잭션 경계설정을 해야 한다는 사실은 피할 수 없다. 따라서 그 안에서 Connection을 생성하고 트랜잭션 시작과 종료를 관리하게 된다. 대신 여기서 생성된 Connection 오브젝트를 계속 메소드 파라미터로 전달하다가 DAO 호출 시 사용하는건 피하는 방법으로 스프링에서는 독립적인 트랜잭션 동기화 방식을 제공한다.
1. UserService에서 Connection을 생성
2. 이를 트랜잭션 동기화에 저장한 뒤 Connection autoCommit(false) 설정
3. 첫 번째 update() 호출 시
4. 트랜잭션 동기화 저장소에 현재 트랜잭션이 시작된 Connection이 있는지 확인
5. 2. 에서 저장한 트랜잭션을 가져와 SQL 실행 후 Connection을 닫지 않고 종료
6. ... 마찬가지로 1~5 방법을 반복하여 정상적으로 끝났으면 UserService는 이제 Connection의 commit()을 호출하여 트랜잭션을 종료시킨다.
7. 트랜잭션 저장소가 더 이상 Connection 오브젝트를 저장해두지 않도록 제거한다.
트랜잭션 동기화 적용
스프링은 멀티스레드 환경에서도 안전한 트랜잭션 동기화 방법을 구현하는 방법을 제공한다.
private DataSource dataSource; public UserSErvice(DataSource dataSource) { ... this.dataSource = dataSource } public void upgradeLevels() throws Exception { TransactionSynchronizationManager.initSynchronization(); // DB 커넥션을 생성하고 트랜잭션을 시작한다. // 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행된다. // DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드 Connection c = DataSourceUtils.getConnection(dataSource); c.setAutoCommit(false); try { List<User> users = userDao.getAll(); for (User user : users) { if (canUpgradeLevel(user)) { upgradeUser(user); } } // 정상시 트랜잭션 커밋 c.commit(); } catch (Exception e) { // 예외가 발생시 트랜잭션 롤백한다. c.rollback(); throw e; } finally { DataSourceUtils.releaseConnection(c, dataSource); // 스프링 유틸리티 메소드를 이용해 DB 커넥션을 안전하게 닫는다. // 동기화 작업 종료 및 정리 TransactionSynchronizationManager.unbindResource(this.dataSource); TransactionSynchronizationManager.clearSynchronization(); } } ... @Bean public UserService userService() { return new UserService(userDao(), dataSource()); }
- TransactionSynchronizationManager 클래스를 이용해 트랜잭션 동기화 작업을 초기화 한다.
- DataSourceUtils에서 제공한 getConnection을 통해 DB커넥션을 생성한다.
- DataSourceUtils getConnection() 메소드는 Connection 오브젝트를 생성 및 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.
- 동기화 준비 후 트랜잭션을 시작하고 DAO의 메소드를 사용하는 트랜잭션 내의 작업을 진행한다.
- 트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate의 작업에서 동기화시킨 DB 커넥션을 사용한다.
- 작업을 정상적으로 마치면 트랜잭션을 커밋해준다.
- 스프링 유틸리티 메소드의 도움을 받아 커넥션을 닫고 트랜잭션동기화를 마치도록 요청하면 된다.(예외시 롤백)
@Test @DisplayName("예외 발생 시 작업 취소 여부 테스트") public void upgradeAllOrNothing() throws Exception { userDao.deleteAll(); UserService testUserService = new TestUserService(this.userDao, users.get(3).getId(), dataSource); for(User user : users) userDao.add(user); try { testUserService.upgradeLevels(); Assertions.fail("TestUserServiceException expected"); } catch (TestUserServiceException e) { } checkLevelUpgraded(users.get(1), false); }
이제는 테스트가 성공할 것이다.
JDBCTemplate과 트랜잭션 동기화
JdbcTemplate은 영리하게 동작하도록 설계되어 있다. 만약 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate이 직업 DB커넥션을 생성하고 트랜잭션을 시작해서 작업을 진행한다.
반면
이미 트랜잭션 동기화를 시작해놓았다면 그때부터 실행되는 JdbcTemplate 메소드에서는 직접 DB 커넥션을 만드는게 아닌 트랜잭션 동기화 저장소에 들어있는 DB 커넥션을 가져와서 사용한다.
트랜잭션 서비스 추상화
한 개 이상의 DB를 이용하여 작업을 하나의 트랜잭션으로 만드는건 JDBC의 Connection을 이용한 로컬 트랜잭션으로는 불가능하다.
- 로컬 트랜잭션은 하나의 DB커넥션에 종속되기 때문이다.
기술과 환경에 종속되는 트랜잭션 경계설정 코드
그렇기에 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션 방식을 사용해야 한다.
1. 글로벌 트랜잭션을 적용해야 트랜잭션 매니저를 통해 여러 개의 DB가 참여하는 작업을 하나의 트랜잭션으로 만들 수 있다.
2. JMS와 같은 트랜잭션 기능을 지원하는 서비스도 트랜잭션에 참여시킬 수 있다.
자바는 JDBC외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한 API인 JTA를 제공한다. (추후 11장에서 설명)
- 하나 이상의 DB가 참여하는 트랜잭션을 만드려면 JTA를 사용해야 하는점만 알아두자
문제는 JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션 코드를 사용하려면 UserService가 수정되어야 한다.
- 본인의 로직이 바뀌지 않았음에도 기술환경에 따라 코드가 바뀌어 버리게 된다.
하이버네이트 같은 다른 데이터 엑세스 기술을 이용한 트랜잭션 관리 코드는 JDBC와 JTA의 코드와는 다르게 Connection이 아니라 Session을 이용하며, 독자적인 트랜잭션 관리 API를 사용한다.
트랜잭션 API의 의존관계 문제와 해결책
UserService는 UserDao 인터페이스에만 의존하는 구조였기에 구현 기술이 변경되더라도 UserService 코드는 영향을 받지 않았다.
하지만 JDBC의 종속적인 Connection을 이용한 트랜잭션 코드가 UserService에 등장하면서부터 UserService는 UserDaoJdbc에 간접적으로 의존하는 코드가 돼버렸다는 점이다.
이를 해결하기 위하여 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조이기에 여러 기술의 사용 방법에 공통점이 있다면 추상화를 생각해볼 수 있다.
스프링의 트랜잭션 서비스 추상화
스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용하면 애플리케이션에서 직접 각 기술의 트랜잭션 API를 이용하지 않고도, 일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능하다.
PlatformTransactionManager 인터페이스를 통하여 JDBC를 사용한다면
PlatformTransactionManager을 구현한 DataSourceTransactionManager을 사용하면 된다.
public void upgradeLevels() throws Exception { // JDBC 트랜잭션 추상 오브젝트 생성 PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); // 트랜잭션 시작 TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // 트랜잭션 안에서 진행되는 작업 List<User> users = userDao.getAll(); users.stream().forEach(user -> { if(canUpgradeLevel(user)) { upgradeLevel(user); } }); // 트랜잭션 커밋 transactionManager.commit(status); } catch (RuntimeException e) { // 트랜잭션 커밋 transactionManager.rollback(status); throw e; } }
스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스를 사용하여 사용할 데이터 엑세스 기술에 맞는 구현체를 DB의 DataSource를 넣어 구현한다.
JDBC를 이용하여 Connection을 생성 후 트랜잭션을 시작한 반면 PlatformTransactionManager에서는 getTransaction() 메소드를 호출하기만 하면 트랜잭션을 가져오게 된다. 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업고 같이 수행해주기 때문이다.
여기서 트랜잭션을 가지고 온다는것은 일단 트랜잭션을 시작하는 의미로 생각하자. 파라미터로 넘기는 DefaultTransactionDefinition 오브젝트는 트랜잭션에 대한 속성을 담고 있다.(추후 살필 예정)
트랜잭션이 시작됬으니 앞서 적용해봤던 트랜잭션 동기화를 사용하여 PlatformTransactionManager로 시작한 트랜잭션은 트랜잭션 동기화 저장소에 저장된다.
트랜잭션 작업을 모두 수행한 후에는 트랜잭션을 돌려받은 TransactionStatus 오브젝트 파라미터로 해서 PlatformTransactionManager의 commit() 메소드를 호출하면 된다. 예외시 rollback()
트랜잭션 기술 설정의 분리
PlatformTransactionManager를 DI 받도록 수정하며 사용하는 데이터엑시스 기술에 맞는 TranscationManager의 클래스만 변경해주면 된다.
서비스 추상화와 단일 책임 원칙
수직, 수평 계층구조와 의존관계
이렇게 기술과 서비스에 대한 추상화 기법을 이용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다. UserDao와 UserService는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 불필요한 영향을 주지 않으며 않으면서 독자적으로 확장이 가능하도록 만든 것이다. 같은 애플리케이션 로직을 담은 코드지만 내용에 따라 분리했다. 같은 계층에서 수평적인 분리라고 볼 수 있다.
@Transactional을 사용하면 해결될 일이지만 해당 어노테이션이 어떻게 동작하는지와 이게 스프링 3대요소인 PSA란걸 알 수 있었다. PSA 참고링크 https://atoz-develop.tistory.com/entry/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-PSA
애플리케이션 계층 : UserService, UserDao
서비스 추상화 계층 : TransactionManager, DataSource
기술 서비스 계층 : JDBC, JTA, Connection Pooling, JNDI, WAS, Database...
UserDao와 DB연결 기술은 DataSource에게만 의존하기에 결합도가 낮은것 처럼 UserService의 트랜잭션도 스프링이 제공하는 PlatformTranscationManager 인터페이스를 통한 추상화 계층을 사이에 두고 사용하였기에 결합도가 낮아졌다.
단일 책임의 원칙 및 장점
단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미이며, 하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수도 있다. 변경의 이유가 2가지 이상이라면 단일 책임의 원칙을 지키지 못한 것이다.
- 단일 책임 원칙을 잘 지키고 있다면, 변경사항에 따른 수정 대상이 명확해진다.
- 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업은 갈수록 복잡해지는 엔터프라이즈 애플리케이션에는 반드시 필요하다. 이를 위한 핵심적인 도구가 바로 DI이다.
- 스프링의 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심 엔진이자 원리이며, 스프링이 지지하고 지원하는, 좋은 설계와 코드를 만드는 모든 과정에서 사용되는 가장 중요한 도구다.
메일 서비스 추상화
단순한 JavaMail을 이용해 메일을 발송하는 가장 전형적인 코드로 구성되어 있다.
메일서버가 켜있다고 가정이 있어야 하며, 매번 메일을 전송하는게 올바른가?
- 최초 테스트로 메일전송이 성공할 경우 추후 테스트에도 동일하게 메일을 전송하는건 비효율적이다
- 네트워크 및 DB를 연동하여 테스트를 진행하는것은 최초 성공여부 이후에는 다른 객체를 이용하여 테스트 하는것이 효율적이다.
- JavaMail의 경우 확장이나 지원이 거의 불가능하며, 추상화만 지원하기에 메일전송 기능 및 트랜잭션 기능을 추상화 하여 테스트용 인터페이스 객체를 제작 하여 사용하면 된다. (Mock객체로 대체가능)
- 네트워크 및 DB를 연동하여 테스트를 진행하는것은 최초 성공여부 이후에는 다른 객체를 이용하여 테스트 하는것이 효율적이다.
의존 오브젝트 교체를 통한 테스트 방식
전에 했던 Mock 예제 hodolee246.tistory.com/59
테스트 대역 참고 beomseok95.tistory.com/295
Dummy - Fake -Stub - Mock
정리
- 비지니스 로직을 담은 코드는 데이터 엑세스 로직을 담은 코드와 깔끔하게 분리되는 것이 바람직하며, 비지니스 로직 코드 또한 내부적으로 책임과 역활에 따라서 깔금하게 메소드로 정리돼야 한다.
- 이를 위해서는 DAO의 기술 변화에 서비스 계층의 코드가 영향을 받지 않도록 인터페이스와 DI를 잘 활용해서 결합도를 낮춰줘야 한다.
- DAO를 사용하는 지니스 로직에는 단위 작업을 보장해주는 트랜잭션이 필요하며, 이의 시작과 끝을 지정하는 일을 트랜잭션 경계설정이라 한다. 이는 주로 비지니스 로직에서 일어난다.
- 시작된 트랜잭션 정보를 담은 오브젝트를 파라미터로 DAO에 전달하는 방법은 매우 비효율적이기 때문에 스프링이 제공하는 트랜잭션 동기화 기법을 활용하는 것이 편리하다.
- 자바에서 사용되는 트랜잭션 API의 종류와 방법은 다양하다. 환경과 서버에 따라서 트랜잭션 방법이 변경되면 경계설정 코드도 함께 변경돼야 한다.
- 트랜잭션 방법에 따라 비즈니스 로직을 담은 코드가 함께 변경되면 단일 책임 원칙에 위배 되며, DAO가 사용하는 특정 기술에 대해 강한 결합을 만들어낸다.
- 트랜잭션 경계설정 코드가 비즈니스 로직 코드에 영향을 주지 않게 하려면 스프링이 제공하는 트랜잭션 서비스 추상화를 이용하면 된다.
- 서비스 추상화는 로우레벨의 트랜잭션 기술과 API의 변화에 상관없이 일관된 API를 가진 추상화 계층을 도입한다.
- 서비스 추상화는 테스트하기 어려운 JavaMail 같은 기술에도 적용할 수 있으며, 테스트를 편리하게 하는것만으로 추상화 의미가 있다.
- 테스트 대상이 사용하는 의존 오브젝트를 대체할 수 있도록 만든 오브젝트를 테스트 대역이라 한다.
- 더미 - 페이크 - 스텁 -목
- 테스트 대역은 테스트 대상 오브젝트가 원할하게 동작할 수 있도록 도우면서 테스트를 위해 간접적인 정보를 제공해주기도 한다.
- 테스트 대역 중에서 테스트 대상으로부터 전달받은 정보를 검증할 수 있도록 설계된 것을 목 오브젝트라고 한다.
'Spring' 카테고리의 다른 글
토비의 스프링[7] 스프링 핵심기술과 응용 (0) 2021.04.14 토비의 스프링[6] AOP (0) 2021.03.08 Spring Boot Test (0) 2021.02.09 토비의 스프링[4] 예외처리 (0) 2021.01.27 토비의 스프링[3] 템플릿 (0) 2021.01.14