토비의 스프링 Vol.1 - 5장 서비스 추상화
서비스 추상화
사용자 레벨 관리 기능 추가
- 사용자의 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나다.
- 사용자가 처음 가입하면 BASIC 레벨이 되며, 이후 활동에 따라서 한 단계씩 업그레이드될 수 있다.
- 가입 후 50회 이상 로그인을 하면 BASIC에서 SILVER 레벨이 된다.
- SILVER 레벨이면서 30번 이상 추천을 받으면 GOLD 레벨이 된다.
- 사용자 레벨의 변경 작업은 일정한 주기를 가지고 일괄적으로 진행된다. 변경 작업 전에는 조건을 충족하더라도 레벨의 변경이 일어나지 않는다.
필드 추가
Level 이늄
DB에 varchar 타입으로 선언하고 “BASIC”, “SILVER, “GOLD”라고 문자를 넣는 방법보다는 각 레벨을 코드화해서 숫자로 넣는다. 범위가 작은 숫자로 관리하면 DB 용량도 많이 차지하지 않고 가벼워서 좋다.
자바의 User에 추가할 프로퍼티 타입은 의미없는 숫자를 프로퍼티에 사용하면 타입이 안전하지 않아서 위험할 수 있기 때문에 별로 좋지 않다. 또, final 상수로 정의해도 타입이 int이기 때문에 다른 종류의 정보를 넣는 실수를 해도 컴파일러가 체크해주지 못한다.
그래서 숫자 타입을 직접 사용하는 것보다는 자바 5 이상에서 제공하는 이늄(enum)을 이용하는게 안전하고 편리하다. ``` public enum Level { BASIC(1), SILVER(2), GOLD(3); // 세 개의 이늄 오브젝트 정의
private final int value;
Level(int value) { // DB에 저장할 값을 넣어줄 생성자. this.value = value; }
public int intValue { // 값을 가져오는 메소드 return value; }
public static Level valueOf(int value) { // 값으로부터 Level 타입 오브젝트를 가져오도록 만든 스태틱 메소드 switch(value) { case 1: return BASIC; case 2: return SILVER; case 3: return GOLD; default: throw new AssertionError(“Unknown value: “ + value); } } }
> 이렇게 만들어진 Level 이늄은 내부에는 DB에 저장할 int 타입의 값을 갖고 있지만, 겉으로는 Level 타입의 오브젝트이기 때문에 안전하게 사용할 수 있다.
user1.setLevel(1000)과 같은 코드는 컴파일러가 타입이 일치하지 않는다는 에러를 내면서 걸러줄 것이다.
###### User 필드 추가 (로그인 횟수와 추천수도)
public class User { … Level level; int login; int recommend;
public Level getLevel() { return level; }
public void setLevel(Level level) { this.level = level; } … // login, recommend getter/setter 생략 }
add() 메소드의 경우 Level 이늄은 오브젝트이므로 DB에 저장될 수 있는 SQL 타입이 아니다. 따라서 DB에 저장 가능한 정수형 값으로 변환해줘야 한다. 각
Level 이늄의 DB 저장용 값을 얻기 위해서는 Level에 미리 만들어둔 intValue() 메소드를 사용한다.
반대로 조회를 했을 경우, ResultSet에서는 DB의 타입인 int로 level 정보를 가져온다. 이 값을 User의 setLevel() 메소드에 전달하면 타입이 일치하지 않는다는
에러가 발생할 것이다. 이 때는 Level의 스태틱 메소드는 valueOf()를 이용해 int 타입의 값을 Level 타입의 이늄 오브젝트로 만들어서 setLevel() 메소드에
넣어줘야 한다.
#### UserService.upgradeLevels(), UserService.add()
DAO는 데이터를 어떻게 가져오고 조작할지를 다루는 곳이다. 비즈니스 로직을 두는 곳이 아니다. 따라서 비즈니스 로직을 담을 클래스를 새로 추가한다.
#### 코드 개선
* 코드에 중복된 부분은 없는가?
* 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?
* 코드가 자신이 있어야 할 자리에 있는가?
* 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?
###### upgradeLevels() 메소드 코드의 문제점
if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) { // 현재 레벨이 무엇인지 파악, 업그레이드 조건 user.setLevel(Level.SILVER); // 레벨 설정 changed = true; // 변화체크 } …
if(changed) { userDao.upgrade(user); } // 변화가 있다면 upgrade
###### upgradeLevels() 리팩토링
public void upgrade Levels() {
List
private boolean canUpgradeLevel(User user) { Level currentLevel = user.getLevel(); switch(currentLevel) { // 레벨별로 구분해서 조건을 판단 case BASIC: return (user.getLogin() >= 50); case SILVER: return (user.getRecommend() >= 30); case GOLD: return false; // 현재 로직에서 다룰 수 없는 레벨이 주어지면 예외를 발생시킨다. 새로운 레벨이 추가되고 로직을 수정하지 않으면 에러가 나서 확인할 수 있다. default: throw new IllegalArgumentException(“Unknown Level: “ + currentLevel); } }
업그레이드 작업용 메소드를 따로 분리해두면 나중에 작업 내용이 추가되더라도 어느 고을 수정해야 할지가 명확해진다.
private void upgradeLevel(User user) { if (user.getLevel() == Level.BASIC) user.setLevel(Level.SILVER); else if (user.getLevel() == Level.SILVER) user.setLevel(Level.GOLD); userDao.update(user); }
레벨의 순서와 다음 단계 레벨이 무엇인지를 결정하는 일은 Level에게 맡기자.
public enum Level { BASIC(1, SILVER), SILVER(2, GOLD), GOLD(3, null); // DB에 저장할 값과 다음 단계의 레벨 정보도 추가.
private final int value; private fianl Level next; // 다음 단계의 레벨 정보를 스스로 갖고 있도록
Level(int value, Level next) { this.value = value; this.next = next; }
public int intValue { // 값을 가져오는 메소드 return value; }
public Level nextLevel { // 값을 가져오는 메소드 return this.next;; }
public static Level valueOf(int value) { // 값으로부터 Level 타입 오브젝트를 가져오도록 만든 스태틱 메소드 switch(value) { case 1: return BASIC; case 2: return SILVER; case 3: return GOLD; default: throw new AssertionError(“Unknown value: “ + value); } } }
> 이렇게 만들어두면 레벨의 업그레이드 순서는 Level 이늄 안에서 관리할 수 있다. 다음 단계의 레벨이 무엇인지를 일일이 if 조건식을 만들어서 비즈니스 로직에
담아둘 필요가 없다.
사용자 정보가 바뀌는 부분을 UserSErvice 메소드에서 User로 옮기자.
public void upgradeLevel() { Level nextLevel = this.level.nextLevel(); if (nextLevel == null) { throw new IllegalStateException(this.level + “은 업그레이드가 불가능합니다”); } else { this.level = nextLevel; } }
간결해진 upgradeLevel()
private void upgradeLevel(User user) { user.upgradeLevel(); userDao.update(user); }
> if 문장이 많이 들어 있던 이전 코드보다 간결하고 작업 내용이 명확하게 드러나는 코드가 됐다. 각 오브젝트가 해야 할 책임도 깔끔하게 분리가 됐다.
> 숫자의 중복 또한 정수형 상수로 변경하여 사용하는 것이 좋다. 업그레이드 조건이 바뀌더라도 상수 값만 변경해주면 된다.
<hr/>
### 트랜잭션 서비스 추상화
#### 모 아니면 도
트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다. 작업을 쪼개서 작은 단위로 만들 수 없다는 것은 트랜잭션의 핵심 속성인 원자성을 의미한다.
#### 트랜잭션 경계설정
DB는 그 자체로 완벽한 트랜잭션을 지원한다. SQL을 이용해 다중 로우의 수정이나 삭제를 위한 요청을 했을 때 일부 로우만 삭제되고 나머지는 안 된다거나, 일부 필드는 수정했는데 나머니 필드는 수정이 안 되고 실패로 끝나는 경우는 없다. 하나의 SQL 명령을 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.
하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다. (계좌이체) 두 가지 작업이 하나의 트랜잭션이 되려면, 두 번째 SQL이 성공적으로 DB 에서 수행되기 전에 문제가 발생할 경우에는 앞에서 처리한 SQL 작업도 취소시켜야 한다. 이런 취소 작업을 **트랜잭션 롤백**이라고 한다.
반대로 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업이 다 성공적으로 마무리 됐다고 DB에 알려줘서 작업을 확정시켜야 한다. 이것을 **트랜잭션 커밋**이라고 한다.
###### JDBC 트랜잭션의 트랜잭션 경계설정
모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두 가지다. 모든 작업을 무효화하는 롤백과 모든 작업을 다 확정하는 커멧이다. 애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부른다.
Connection c = dataSource.getConnection();
c.setAutoCommit(false); // 트랜잭션 시작 try { … … c.commit(); // 트랜잭션 커밋 } catch (Exception e) { c.rollback(); // 트랜잭션 롤백 }
c.close();
JDBC의 트랜잭션은 하나의 Connection을 가져와 사용하다가 일어난다.<br/>
> 이렇게 setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 **트랜잭션의 경계설정**이라고 한다.
> 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 **로컬 트랜잭션**이라고도 한다.
###### UserService와 UserDao의 트랜잭션 문제
DAO 메소드를 호출할 때마다 하나의 새로운 트랜잭션이 만들어 진다. DAO 메소드 내에서 JDBC API를 직접 사용하든 JdbcTemplate을 이용하든 마찬가지다. DAO 메소드 내에서 DB 커넥션을 매번 만들기 때문에 어쩔 수 없다. DAO를 사용하면 비즈니스 로직을 담고 있는 UserService 내에서 진행되는 여러 가지 작업을 하나의 트랜잭션으로 묶는 일이 불가능해진다.
어떤 일련의 작업이 하나의 트랜잭션으로 묶이려면 그 작업이 진행되는 동안 DB 커넥션도 하나만 사용돼야 한다. 트랜잭션은 Connection 오브젝트 안에서 만들어지기 때문이다.
###### 비즈니스 로직 내의 트랜잭션 경계설정
public void upgradeLevels() throws Exception { (1) DB Connection 생성 (2) 트랜잭션 시작 try { (3) DAO 메소드 호출 (4) 트랜잭션 커밋 } catch(Exception e) { (5) 트랜잭션 롤백 throw e; } finally { (6) DB Connection 종료 } }
여기서 순수한 데이터 액세스 로직은 DAO에 두어야 한다. 그리고 데이터 액세스 메소드는 위의 Connection을 사용해야 한다. 그래야만 같은 트랜잭션 안에서 동작하기 때문이다. 따라서 DAO메소드들은 Connection 오브젝트를 파라미터로 받아야 한다.
public interface UserDao { public void add(Connection c, User user); public User get(Connection c, String id); … public void update(Connection c, User user1); }
upgradeLevels()에서 upgradeLevel(Connection c, User user)를 호출하고, upgradeLevel(Connection c, User user)에서 UserDao를 호출하게 한다.
###### UserService 트랜잭션 경계설정의 문제점
* DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다. 결국 JDBC API를 직접 사용하는 초기 방식으로 돌아가야 한다.
* 메소드들 사이에서 Connection 오브젝트를 계속 전달해야 한다.
* UserDao는 더 이상 데이터 액세스 기술에 독립적일 수 없다. DAO 기술이 변경하면 Connection을 다른 기술에 맞게 수정해줘한다.
#### 트랜잭션 동기화
###### Connection 파라미터 제거
트랙잭션 동기화란 UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서는 저장된 Connection을 가져다가 사용하게 하는 것이다. DAO가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것이다. 그리고 트랜잭션이 모두 종료되면, 그때는 동기화를 마치면 된다.
1. UserService는 Connection을 생성하고
2. 이를 트랜잭션 동기화 저장소에 저장해두고 Connection의 setAutoCommit(false)를 호출해 트랜잭션을 시작시킨 후에 본격적으로 DAO의 기능을 이용하기 시작한다.
3. 첫 번째 update() 메소드가 호출되고, update() 메소드 내부에서 이용하는 JdbcTemplate 메소드에서는 가장 먼저
4. 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인한다.
5. Connection을 이용해 PreparedStatement를 만들어 수정 SQL을 실행한다. 트랜잭션 동기화 저장소에서 DB 커넥션을 가져왔을 때는 JdbcTemplate은 Connection을 닫지 않은 채로 작업을 마친다. 이렇게 해서 트랜잭션 안에서 첫 번째 DB 작업을 마쳤다. 여전히 Connection은 열려 있고 트랜잭션은 진행중인 채로 트랜잭션 동기화 저장소에 저장되어 있다.
6. 두 번째 update()가 호출되면 이때도 마찬가지로
7. 트랜잭션 동기화 저장소에서 Connection을 가져와
8. 사용한다.
9. 마지막 update()도
10. 같은 트랜잭션을 가진 Connection을 가져와
11. 사용한다.
12. 트랜잭션 내의 모든 작업이 정상적으로 끝났으면 UserService는 이제 Connection의 commit()을 호출해서 트랜잭션을 완료시킨다.
13. 마지막으로 트랜잭션 저장소가 더 이상 Connection 오브젝트를 저장해두지 않도록 이를 제거한다.
어느 작업 중에라도 예외상황이 발생하면 UserService는 즉시 Connection의 rollback()을 호출하고 트랜잭션을 종료할 수 있다. 물론 이때도 트랜잭션 저장소에 저장된 동기화된 Connection 오브젝트는 제거해줘야 한다.
> 이렇게 트랜잭션 동기화 기법을 사용하면 파라미터를 통해 일일이 Connection 오브젝트를 전달할 필요가 없어진다.
// Connction을 생성할 때 사용할 DataSource를 DI 받도록 한다. private DataSource dataSource;
public void setDataSource(DataSource dataSource) { this.dataSource = dataSource; }
public void upgradeLevels() throws Exception { TransactionSynchronizationManager.initSynchronization(); // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화한다. Connection c = DataSourceUtils.getConnection(dataSource); // DB 커넥션을 생성하고 트랜잭션을 시작한다. c.setAutoCommit(false); 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행된다.
try {
List
UserService에서 DB 커넥션을 직접 다룰 때 DataSource가 필요하므로 DataSource 빈에 대한 DI 설정을 해둬야 한다.
스프링이 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager다. 이 클래스를 이용해 먼저 트랜잭션 동기화 작업을 초기화하도록 요청한다. 그리고 DataSourceUtils에서 제공하는 getConnection() 메소드를 통해 DB 커넥션을 생성한다. DataSource에서 Connection을 직접 가져오지 않고, 스프링이 제공하는 유틸리티 메소드를 쓰는 이유는 이 DataSourceUtils의 getConnection() 메소드는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바이딩해주기 때문이다. 동기화 준비가 됐으면 트랜잭션을 시작하고 DAO의 메소드를 사용하는 트랜잭션 내의 작업을 진행한다. 트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate의 작업에서 동기화시킨 DB 커넥션을 사용하게 된다.
###### JdbcTemplate과 트랜잭션 동기화
JdbcTemplate은 만약 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate이 직접 DB 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행한다. 반면에 트랜잭션 동기화를 미리 시작해 놓았다면 그때부터 실행되는 JdbcTemplate의 메소드에서는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 들어있는 DB 커넥션을 가져와서 사용한다.
> 따라서 DAO를 사용할 때 트랜잭션이 굳이 필요 없다면 바로 호출해서 사용해도 되고, DAO 외부에서 트랜잭션을 만들고 이를 관리할 필요가 있다면 미리 DB 커넥션을 생성한 다음 트랜잭션 동기화를 해주고 사용하면 된다.
#### 트랜잭션 서비스 추상화
한 개 이상의 DB로의 작업을 하나의 트랜잭션으로 만드는 건 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜잭션으로는 불가능하다. 왜냐하면 로컬 트랜잭션은 하나의 DB Connection에 종속되기 때문이다. 따라서 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라, 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 **글로벌 트랜잭션** 방식을 사용해야 한다.<br/>
> 자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한 API인 JTA(Java Transaction API)를 제공하고 있다.
> JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.
UserService가 트랜잭션 API에 의존하게 되어 자신의 로직이 바뀌지 않았음에도 기술환경에 따라서 코드가 바뀌게 된다.
###### 트랜잭션 API의 의존관계 문제와 해결책
트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조다. -> 추상화를 생각해 볼 수 있다.<br/>
추상화란 하위 시스템의 공통점을 뽑아내서 분리시키는 것이다.
###### 스프링의 트랜잭션 서비스 추상화
스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공한다. 이를 이용하면 애플리케이션에서 직접 각 기술의 트랜잭션 API를 이용하지 않고도, 일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능해진다.
* 스프링의 트랜잭션 추상화 API를 적용
public void upgradeLevels() {
// JDBC 트랜잭션 추상 오브젝트 생성
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
List
스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스는 PlatformTransactionManager다. JDBC의 로컬 트랜잭션을 이용한다면 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하면 된다. 사용할 DB의 DataSource를 생성자 파라미터로 넣으면서 DataSourceTransactionManager의 오브젝트를 만든다.
JDBC를 이용하는 경우에는 먼저 Connection을 생성하고 나서 트랜잭션을 시작했다. 하지만 PlatformTransactionManager에서는 트랜잭션을 가져오는 요청인 getTransaction() 메소드를 호출하기만 하면 된다. 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 같이 수행해주기 때문이다. 파라미터로 넘기는 DefaultTransactionDefinition 오브젝트는 트랜잭션에 대한 속성을 담고 있다.
이렇게 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장된다. TransctionStatus는 트랜잭션에 대한 조작이 필요할 때 PlatformTransactionMansger 메소드의 파라미터로 전달해주면 된다.
스프링의 트랜잭션 추상화 기술은 트랜잭션 동기화를 사용한다. PlatformTransactionManager로 시작한 트랜잭션은 트랜잭션 동기화 저장소에 저장된다. PlatformTransactionManager를 구현한 DataSourceTransactionManager 오브젝트는 JdbcTemplate에서 사용될 수 있는 방식으로 트랜잭션을 관리해준다. 따라서 PlatformTransactionManager를 통해 시작한 트랜잭션은 UserDao의 JdbcTemplate 안에서 사용된다.
트랜잭션 작업을 모두 수행한 후에는 트랜잭션을 만들 때 돌려받은 TransactionStatus 오브젝트를 파라미터로 해서 PlatformTransactionManager의 commit() 메소드를 호출하면 된다. 예외가 발생하면 rollback() 메소드를 부른다.
###### 트랜잭션 기술 설정의 분리
트랜잭션 추상화 API를 적용한 UserService 코드를 다른 클로벌 트랜잭션으로 변경하려면 단 한 줄만 변경하면 된다.
* JTA
* PlatformTransactionManager transactionManager = new JTATransactionManager(dataSource);
* 하이버네이트
* PlatformTransactionManager transactionManager = new HibernateTransactionManager(dataSource);
* JPA
* PlatformTransactionManager transactionManager = new JPATransactionManager(dataSource);
어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService 코드가 알고 있는 것은 DI원칙에 위배된다.
* 트랜잭션 매니저를 빈으로 분리시킨 UserService
public class UserService { … private PlatformTransactionManager transactionManager;
public void setTransactionManager(PlatformTransactionManager transactionManager) { this.transactionManager = transactionManager; }
public void upgradeLevels() {
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
List
* 트랜잭션 매니저 빈을 등록할 설정파일
<hr/>
### 서비스 추상화와 단일 책임 원칙
#### 수직, 수평 계층구조와 의존관계
기술과 서비스에 대한 추상화 기법을 이용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다.
* 애플리케이션 계층 - UserService, UserDao등
* 서비스 추상화 계층 - TransactionManager, DataSource등
* 기술 서비스 계층 - JDBC, JTA, Connection Pooling, JNDI, WAS, Database등<br/>
> 각 계층은 수직적인 계층구조이다.(로직과 기술)
> UserService와 UserDao는 수평적인 계층구조이다. 같은 애플리케이션 걔층에서 내용에 따라 분리함. (애플리케이션 로직의 종류)
###### 단일 책임 원칙
하나의 모듈은 한 가지 책임을 가져야 한다. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다.
###### 단일 책임 원칙의 장점
* 어떤 변경이 필요할 때 수정 대상이 명확해진다.
> 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업은 갈수록 복잡해지는 엔터프라이즈 애플리케이션에는 반드시 필요하다.
이를 위한 핵심적인 도구가 바로 스프링이 제공하는 DI다. 스프링의 DI가 없었다면 인터페이스를 도입해서 나름 추상화를 했더라도 적지 않은 코드 사이의 결합이 남아 있게 된다.
<hr/>
### 메일 서비스 추상화
#### JavaMail을 이용한 메일 발송 기능
###### JavaMail 메일 발송
자바에서 메일을 발송할 때는 표준 기술인 JavaMail을 사용하면 된다. javax.mail 패키지에서 제공하는 자바의 이메일 클래스를 사용한다.
테스트시 불필요한 메일 전송을 하지 않기 위해 JavaMail 대신 테스트용 JavaMail로 대체해서 사용하자.
###### JavaMail을 이용한 테스트의 문제점
JavaMail에서는 Session 오브젝트를 만들어야만 메일 메시지를 생성할 수 있고, 메일을 정송할 수 있다. 그런데 이 Session은 인터페이스가 아니고 클래스다. 게다가 생성자 모두 private 으로 되어 있다. 또 상속이 불가능한 final 클래스다. MailMessage, Transport도 마찬가지다.
> JavaMail은 확장이나 지원이 불가능하도록 만들어진 가장 악명 높은 표준 API 중의 하나로 알려져 있다.
> 스프링은 JavaMail을 사용해 만든 코드는 손쉽게 테스트하기 힘들다는 문제를 해결하기 위해서 JavaMail에 대한 추상화 기능을 제공하고 있다.
###### 메일 발송 기능 추상화
* JavaMail의 서비스 추상화 인터페이스
public interface MailSender { void send(SimpleMailMessage simpleMessage) throws MailException; void send(SimpleMailMessage[] simpleMessage) throws MailException; }
* 스프링의 MailSender를 이용한 메일 발송 메소드
private void sendUpgradeEMail(User user) { JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); // MailSender 구현 클래스의 오브젝트를 생성. mailSender.setHost(“mail.server.com”);
// MailMessage 인터페이스의 구현 클래스 오브젝트를 만들어 메일 내용을 작성. SimpleMailMessage mailMessage = new SimpleMailMessage(); mailMessage.setTo(user.getEmail()); mailMessage.setFrom(“useradmin@ksug.org”); mailMessage.setSubject(“Upgrade 안내”); mailMessage.setText(“사용자님의 등급이 “ + user.getLevel().name());
mailSender.send(mailMessage); }
JavaMail API를 사용하는 JavaMailSenderImpl 클래스의 오브젝트를 코드에서 직접 사용하고 있다. 따라서 스프링의 DI를 적용해야 한다.
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 안내”); mailMessage.setText(“사용자님의 등급이 “ + user.getLevel().name());
this.mailSender.send(mailMessage); } } ``` * 메일 발송 오브젝트의 빈 등록 ```
###### 테스트용 메일 발송 오브젝트
JavaMail을 사용하지 않고, 메일 발송 기능이 포함된 코드를 테스트 할 수 있다는 것을 알 수 있다. 이를 위해 메일 전송 기능을 추상화해서 인터페이스를 적용하고 DI를 통해 빈으로 분리해놨다.
스프링이 제공한 메일 전송 기능에 대한 인터페이스가 있으니 이를 구현해서 테스트용 메일 전송 클래스를 만들자. MailSender 인터페이스를 implements하고 각 메소드에서 하는 일은 없다. 다음으로 테스트 설정파일의 mailSender 빈 클래스를 위의 클래스로 변경하고 UserServiceTest에서 @Autowired해주면 된다.
###### 테스트와 서비스 추상화
일반적으로 서비스 추상화라고 하면 트랜잭션과 같이 기능은 유사하나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 추상 인터페이스와 일관성 있는 접근 방법을 제공해주는 것을 말한다. 반면에 JavaMail의 경우처럼 테스트를 어렵게 만드는 건전하지 않은 방식으로 설계된 API를 사용할 때도 유용하게 쓰일 수 있다.
어떤 경우에도 UserService와 같은 애플리케이션 계층의 코드는 아래 계층에서는 어떤 일이 일어나는지 상관없이 메일 발송을 요청한다는 기본 기능에 충실하게 작성하면 된다. 메일 서버가 바뀌고 메일 발송 방식이 바뀌는 등의 변화가 있어도 메일을 발송한다는 비즈니스 로직이 바뀌지 않는 한 UserService는 수정할 필요가 없다.
#### 테스트 대역
###### 테스트 대역의 종류와 특징
테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 이런 오브젝트를 통틀어서 **테스트 대역**이라고 부른다.
대표적인 테스트 대역은 **테스트 스텁**이다. 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다. 일반적으로 테스트 스텁은 메소드를 통해 전달하는 파라미터와 달리, 테스트 코드 내부에서 간접적으로 사용된다. 따러서 DI등을 통해 미리 의존 오브젝트를 테스트 스텁으로 변경해야 한다.
테스트 대상의 간접적인 출력 결과를 검증하고, 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계된 **목 오브젝트**가 있다. 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.
테스트 대상 오브젝트는 테스트로부터만 입력을 받는 것이 아니라, 테스트가 수행되는 동안 실행되는 코드는 테스트 대상이 의존하고 있는 다른 의존 오브젝트와도 커뮤니케이션하기도 한다. 테스트 대상은 의존 오브젝트에게 값을 출력하기도 하고 값을 입력받기도 한다.
###### 목 오브젝트를 이용한 테스트
새로운 MailSender를 대체할 클래스(목 오브젝트로 만든 메일 전송 확인용 클래스)
static class class MockMailSender implements MailSender {
// UserService로부터 전송 요청을 받은 메일 주소를 저장해두고 이를 읽을 수 있게 한다.
private List
public List
public void send(SimpleMailMessage mailMessage) throws MailException { requests.add(mailMessage.getTo()[0]); // 전송 요청을 받은 이메일 주소를 저장해둔다. 간단하게 첫 번째 수신자 메일 주소만 저장. }
public void send(SimpleMailMessage[] mailMessage) throws MailException { } } ```
스프링 설정을 통해 DI된 DummyMailSender를 대신해서 사용할 메일 전송 검증용 목 오브젝트를 준비했다.
목 오브젝트를 이용한 테스트라는 게, 작성하기는 간단하면서도 기능은 상당히 막강하다. 보통의 테스트 방법으로는 검증하기가 매우 까다로운 테스트 대상 오브젝트의 내부에서 일어나는 일이나 다른 오브젝트 사이에서 주고받는 정보까지 검증하는 일이 손쉽기 때문이다.
테스트가 수행될 수 있도록 의존 오브젝트에 간접적으로 입력 값을 제공해주는 스텁 오브젝트와 간접적인 출력 값까지 확인이 가능한 목 오브젝트, 이 두 가지는 테스트 대역의 가장 대표적인 방법이며 효과적인 테스트 코드를 작성하는 데 빠질 수 없는 중요한 도구다.
정리
- 비즈니스 로직을 담은 코드는 데이터 액세스 로직을 담은 코드와 깔끔하게 분리되는 것이 바람직하다. 비즈니스 로직 코드 또한 내부적으로 책임과 역할에 따라서 깔끔하게 메소드로 정리돼야 한다.
- 이를 위해서는 DAO의 기술 변화에 서비스 계층의 코드가 영향을 받지 않도록 인터페이스와 DI를 잘 활용해서 결합도를 낮춰줘야 한다.
- DAO를 사용하는 비즈니스 로직에는 단위 작업을 보장해주는 트랜잭션이 필요하다.
- 트랜잭션의 시작과 종료를 지정하는 일을 트랜잭션 경계설정이라고 한다. 트랜잭션 경계설정은 주로 비즈니스 로직 안에서 일어나는 경우가 많다.
- 시작된 트랜잭션 정보를 담은 오브젝트를 파라미터로 DAO에 전달하는 방법은 매우 비효율적이기 때문에 스프링이 제공하는 동기화 기법을 활용하는 것이 편리하다.
- 자바에서 사용되는 트랜잭션 API의 종류와 방법은 다양하다. 환경과 서버에 따라서 트랜잭션 방법이 변경되면 경계설정 코드도 함께 변경돼야 한다.
- 트랜잭션 방법에 따라 비즈니스 로직을 담은 코드가 함께 변경되면 단일 책임 원칙에 위배되며, DAO가 사용하는 특정 기술에 대해 강한 결합을 만들어낸다.
- 트랜잭션 경계설정 코드가 비즈니스 로직에 영향을 주지 않게 하려면 스프링이 제공하는 트랜잭션 서비스 추상화를 이용하면 된다.
- 서비스 추상화는 로우레벨의 트랜잭션 기술과 API의 변화에 상관없이 일관된 API를 가진 추상화 계층을 도입한다.
- 서비스 추상화는 테스트하기 어려운 JavaMail같은 기술에도 적용할 수 있다. 테스트를 편리하게 작성하도록 도와주는 것만으로도 서비스 추상화는 가치가 있다.
- 테스트 대상이 사용하는 의존 오브젝트를 대체할 수 있도록 만든 오브젝트를 테스트 대역이라고 한다.
- 테스트 대역은 테스트 대상 오브젝트가 원활하게 동작할 수 있도록 도우면서 테스트를 위해 간접적인 정보를 제공해주기도 한다.
- 테스트 대역 중에서 테스트 대상으로부터 전달받은 정보를 검증할 수 있도록 설계된 것을 목 오브젝트라고 한다.
출처 : https://github.com/Masssidev/toby-vol1