추상화
- 하위 레벨 에서의 공통점을 뽑아내서 분리시키는 것을 말한다.
- 하위 레벨의 동작 방식을 몰라도, 또는 하위 레벨이 바뀌더라도 같은 방법으로 접근할 수가 있다.
서비스 추상화
- 기능은 유사하나 사용 방법이 다른, 하위 레벨의 여러 기술에 대해 인터페이스와 일관된 접근을 제공해 주는 것을 말한다.
Level enum class 추가
User 필드 추가
UserDaoTest 수정
UserDaoJdbc 수정
추가할 필드에 오타가 있었다면?
- 기능을 실제 사용해 보지 않고는 찾기 힘듦
- 테스트코드가 있어 미리 잡을 수 있다!!!
사용자 정보에서 id를 제외한 다른 정보는 모두 수정 될 수 있다.
비즈니스 로직을 다루기 위해 UserService 클래스를 만든다.
- UserService는 UserDao에 의존한다.
- DI를 하기 위해서 UserService도 스프링 빈으로 등록한다.
upgradeLevels()
public class UserService {
UserDao userDao;
public void setUserDao(UserDao userDao){
this.userDao = userDao;
}
public void upgradeLevels() {
List<User> users = userDao.getAll();
for(User user : users) {
Boolean changed = null; // 레벨의 변화가 있는지를 확인하는 플래그
if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
// basic 업그레이드
user.setLevel(Level.SILVER);
changed = true; // 레벨 변경 플래그
}
else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
// silver 업그레이드
user.setLevel(Level. GOLD)
changed = true; // 레벨 변경 플래그
}
else if (user.getLevel() == Level.GOLD) {
// gold 변경 없음
changed = false; // 레벨 변경 플래그
}
else {
changed = false; // 레벨 변경 플래그
}
if (changed) { userDao.update(user); }
}
}
회원가입을 하면 최초의 레벨은 basic 이다.
이는 비즈니스 로직이기 때문에 UserService에서 설정해준다.
public void add(User user){
if(user.getLevel() == null) // user의 레벨이 주어지면 그냥 그대로 저장한다.
user.setLevel(Level.BASIC);
UserDao.add(user);
}
기존 코드를 수정하여 테스트하기에 앞서 우리는 다음과 같은 질문을 해볼 필요가 있다.
upgradeLevels() 리팩토링
if-else 체인에서 나는 냄새
- if-else 체인에는 어떤 상태의 변화, 조건, 조건 만족 시 작업이 같이 있음
- 논리의 흐름을 이해하기 어렵다.
이는 성격이 다른 여러 가지 로직이 한데 섞여 있기 때문이다.
-> 관심사의 분리를 통해 이를 해결하자
public void upgradeLevels() {
List<User> users = userDao.getAll();
for(User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
private void upgradeLevel(User user){
user.upgradeLevel();
userDao.update(user);
}
// User.upgradeLevel
private void upgradeLevel() {
Level currentLevel = this.getLevel();
if(currentLevel == null)
throw new IllegalStateException("유저의 레벨이 존재하지 않습니다.");
try {
Level nextLevel = Level.valueOf(++currentLevel.intValue());
this.setLevel(nextLevel);
}
catch(AssertionError e){
throw new IllegalStateException(this.level + " 업그레이드 불가. 이미 최고레벨입니다.");
}
}
레벨 관리 작업중 네트워크나 DB의 문제로 작업 완료가 어려워 졌다.
롤백을 통해 레벨 관리 작업 전으로 돌려놓자.
트랜잭션
: 레벨 관리 작업
- 여러 작업이 하나의 트랜잭션으로 묶이면 하나의 작업으로 취급한다.
- 트랜잭션은 모두 성공하던지 모두 실패하여야 한다.
- 만약 트랜잭션을 완료할 수 없다면, 아예 작업이 시작조차 안한 것 처럼 감쪽같이 돌려놓아야 한다.
롤백
: 레벨 관리 작업 전으로 돌려놓자
- 트랜잭션이 완전히 실행되지 못하면 실행된 작업을 취소시켜야 한다.
- 이를 Transaction Rollback이라고 한다.
커밋
: 한 사용자의 레벨 관리
- 트랜잭션은 여러 작업(커밋)으로 이루어진다.
- 커밋 이후 실행되는 rollback은 가장 최근의 커밋 상태로 돌아온다.
- 커밋에 이름을 붙이고 해당 커밋으로 되돌릴 수 있다.
Connection c = dataSource.getConnection();
c.setAutoCommit(false); // 트랜잭션 시작
try {
PreparedStatement st1 = c.prepareStatement("(update sql)");
st1.executeUpdate();
PreparedStatement st2 = c.prepareStatement("(delete sql)");
st2.executeUpdate();
c.commit(); // 트랜잭션 커밋
} catch (Exception e) {
c.rollback(); // 트랜잭션 롤백
}
c.close();
트랜잭션은 커넥션이 만들어지고 닫히는 범위에 존재한다.
DB 커넥션 내부에서 만들어지는 트랜잭션을 로컬 트랜잭션이라 한다.
JDBC의 트랜잭션 경계 설정은 Connection 오브젝트를 이용한다.
UserDao를 구현하면서 JdbcTemplate의 메소드를 사용하면서 Connection을 직접 조작하지 않았다.
트랜잭션은 일반적으로 커넥션 보다 존재 범위가 짧다.
JdbcTemplate의 메소드를 사용하는 UserDao는 메소드 마다 하나씩의 독립적인 트랜잭션으로 실행된다.
userDao.update()마다 커넥션이 열고 닫히면서 하나의 트랜잭션이 생겨나는 것이다.
트랜잭션 결과는 db에 그대로 남는다.
트랜잭션의 범위설정을 해야한다.
class UserService{
public void upgradeLevels() throws Exception{
Connection c = ... ;
...
try{
...
upgradeLevel(c,user);
...
}
...
}
void upgradeLevel(Connection c, User user){
user.upgradeLevel();
userDao.update(c,user);
}
}
interface UserDao(){
public update(Connection c, User user);
...
}
DB연결을 깔끔하게 처리를 하기위해 JdbcTemplate을 만들었는데, 이 관심사의 분리가 깨졌다.
UserService 메소드에서 넘겨주는 Connection객체로 인해 코드가 지저분해진다.
JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 한다.
스프링은 멀티쓰레드 환경에서도 안전한 트랜잭션을 구성할 수 있게 유틸리티 메소드를 제공한다 : TransactionSynchronizationManager
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
// Connection을 생성할 때 사용할 DataSource를 DI받는다.
this.dataSource = dataSource;
}
public void upgradeLevels() throws Exception { //
TransactionSynchronizationManager.initSynchronization();
// 트랜잭션 동기화 작업을 초기화
Connection c = DataSourceUtils.getConnection(dataSource);
// DB 커넥션을 생성하고 트랜잭션을 시작. 이후 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행
c.setAutoCommit(false);
// 트랜잭션의 시작 선언
// DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드
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 객체
- 트랜잭션의 범위 내에서 여러 작업을 관리
- 트랜잭션의 커밋 또는 롤백 시에 필요한 작업을 수행할 수 있도록 지원
어떤 회사에서 다수의 DB를 백업 용도로 쓰고 있다고 하자.
글로벌 트랜잭션 :
별도의 트랜잭션 관리자를 이용해 트랜잭션을 관리할 수 있다.
JMS와 같이 트랜잭션 기능을 지원하는 서비스도 트랜잭션에 참여 시킬 수 있다.
글로벌 트랜잭션을 제공하는 JTA(Java Transaction API)를 사용한다.
메시징 서버?
- 컴퓨터 시스템 간에 데이터나 메시지를 교환하기 위한 시스템
- 분산 시스템에서 데이터나 정보를 안정적으로 전달하고 처리하기 위해 사용
- 다양한 애플리케이션 간 통신을 지원
JMS : 메시징 시스템을 위한 API
분산 시스템에서 데이터를 안전하고 신뢰성 있게 교환하고 처리하는 데 사용
UserDao는 데이터 접근 기술을 유연하게 바꿔 사용 할 수 있게 구현되어 있었다.
UserService에서 트랜잭션의 경계설정을 하면서 데이터 접근 기술에 종속되는 구조가 되었다.
트랜잭션 처리 코드에 추상화를 도입할 수 있다.
API들의 트랜잭션 경계설정 방법에서 공통적인 특징을 찾아 추상화된 트랝개션 관리 계층을 만들 수 있다.
-> 스프링의 트랜잭션 서비스 추상화
PlatformTransactionManager : 스프링에서 제공하는 트랜잭션 경계설정을 위한 인터페이스
public class UserService{
...
private PlatformTransactionManager transactionManager;
// 변수 이름이 transactionManager인 것은 컨벤션이다.
public void setTransactionManager(PlatformTransactionManager transactionManager){
this.transactionManager = transactionManager;
// 어떤 API를 사용하냐에 따라 의존주입을 통해 transactionManger 객체를 설정 할 수 있다.
}
public void upgradeLevels() {
TransactionStatus status =
this.transactionManager.getTransaction(new DefaultTransactionDefinition());
// getTransaction() 메서드를 호출하기만 하면 트랜잭션 시작 뿐 아니라 커넥션까지 가져올 수 있다
// 트랜잭션은 TransactionStatus 타입의 변수에 저장된다.
// 파라미터로 넘기는 객체는 트랜잭션의 속성을 담고있다.
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;
}
}
}
UserDao와 UserService의 관심사의 분리
서로 불필요한 영향을 주지 않으면서 독자적으로 확장이 가능하도록 만든 것이다.
트랜잭션의 추상화
애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리한 것이다.
DI
DI의 가치는 이렇게 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.
하나의 모듈은 한 가지 책임을 가져야 한다. 하나의 모듈이 바뀌는 이유는 하나여야 한다.
단일 책임 원칙의 장점 :
- 여러 모듈들이 각자의 책임에 충실함으로 코드를 이해하기 쉽다.
- 변경이 필요할 때 수정 대상이 명확해 진다.
- 높은 응집성, 낮은 결합도 :
높은 응집성, 낮은 결합도를 유지함으로 성격이 다른 모듈의 코드가 서로 영향을 주지 않게 되고, 서로 독립적으로 확장될 수 있게 된다.
단일 책임원칙을 돕는 DI :
DI를 통해 성격이 다른 모듈은 외부에서 제어하고 주입받아 사용할 수 있도록 한다.
레벨이 업그레이드 되는 고객에겐 안내 메일을 발송해야 한다고 하자.
User에 Email 필드를 추가하고, UserService의 upgradeLevel()에 메일 발송 기능을 추가해야 한다.
자바에서 메일을 발송할 때는, Java 표준인 JavaMail을 사용할 수 있다.
JavaMail은 오랜 기간 널리 사용된 기술이다.
테스트용 메일 서버를 따로 두고, 메일 서버가 실제 메일을 전송하는 대신 SMTP 프로토콜 요청이 JavaMail로부터 잘 도착하는 지만 확인한다.
(메일 서버는 요청을 잘 수행한다고 가정하자)
JavaMai이 받은 요청을 올바르게 메일 서버로 전달한다는 가정도 포함하면 직접 JavaMail을 구동하는 대신 테스트용 껍데기 JavaMail을 사용, JavaMail이 메일 서버와 통신함으로써 생기는 부하와 리스크도 줄일 수 있다.
어려운 JavaMail 설계
JavaMail과 같은 인터페이스를 갖는 오브젝트를 만들면 된다.
➡️ Spring은 JavaMail을 추상화하는 기능을 제공하고 있다.
MailSender를 구현한 추상화 클래스를 이용하면, JavaMail이 아닌 다른 메세징 서버 API를 이용할 때도 MailSender를 구현한 클래스를 만들어 의존주입을 할 수 있다.
UserService에서는 MailSender 인터페이스를 통해 메일을 보낸다고만 알면 된다.
MailSender 구체 클래스가 바뀌더라도 의존주입만 새로 해주면 된다.
DummyMailSender는 하는 것 없이 테스트를 편히 하기 위해 작성했다.
테스트 때 발송용 메소드를 제거했다가 테스트 끝나고 추가하는 것은 불가능한 일이기 때문에 DummyMailSender를 주입함으로써 간단하게 해결했다.
우리가 테스트하고자 하는 것은 UserService였는데, 이 UserService도 여러 객체에 의존하고 있다.
테스트 환경에서 테스트 대상이 되는 객체의 기능을 충실하게 수행하며 테스트를 자주 실행할 수 있도록 하는 객체를 테스트 대역(test double)이라고 한다.
DummyMailSender가 없다면, 메일 서버가 구비되지 않은 상황에서 실제 메일을 발송할 주소가 존재하지 않아 예외 가 발생해 테스트를 진행하기 어렵다.
DummyMailSender는 테스트에 큰 영향을 끼치지 않는다.
테스트 대상 객체가 의존 객체에 넘기는 값과 행위를 검증하고 싶다면, mock 객체를 사용해야 한다.
Ex ) upgradeLevels() 테스트에 실제 메일 발송을 제대로 요청했는지 알고 싶다면, MailSender를 구현한 MockMailSender를 만들고 Request 횟수를 저장하면 된다.