[Spring] 토비의 스프링 Vol.1 5장 서비스 추상화

Shiba·2023년 8월 25일
0

🍀 스프링 정리

목록 보기
6/21
post-thumbnail

📗 서비스 추상화

❗ 토비의 스프링 3.1 vol 1 정리입니다.
책을 읽지 않으셨다면 이해가 어려울 수 있습니다!


📖 사용자 레벨 관리 기능 추가

레벨을 조정하는 간단한 비즈니스 로직을 구현해보자

<<레벨 관리 비즈니스 로직>>

  • 사용자의 레벨은 BASIC, SILVER, GOLD 중 하나이다.
  • 사용자가 처음 가입하면 BASIC 레벨이 되며, 이후 활동에 따라 한 단계씩 업그레이드된다.
  • 가입 후 50회 이상 로그인을 하면 BASIC에서 SILVER 레벨이 된다.
  • SILVER 레벨에서 30번 이상 추천을 받으면 GOLD레벨이 된다.
  • 사용자 레벨의 변경 작업은 일정한 주기로 일괄 진행.

📝 필드 추가

레벨을 저장할 필드를 추가해보자.

BASIC, SILVER, GOLD를 int 타입으로 설정하면 오류가 생길 가능성이 존재한다.
- 컴파일러가 다른 종류의 정보를 넣었을 때, 이를 체크해주지 못함.
- 우연히 레벨에 대응하는 숫자를 넣거나 해당 값이 범위를 초과해버리면 버그가 발생

=> 따라서, 자바 5 이상에서 제공하는 이늄(enum)을 사용하는게 안전하며 편리하다!

package springbook.user.domain;
...
public enum Level {
	BASIC(1), SILVER(2), GOLD(3); // enum 오브젝트 정의
    
    private final int value;
    
    Level(int value) { // 생성자 만들기
    	this.value = value;
    }
    
    public int intValue() { // 값을 가져오는 메소드
    	return value;
    }
    
    //값으로부터 Level 타입 오브젝트를 가져오도록 만든 스태틱 메소드
    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);
        }
    }
}

// 이제 user1.setLevel(1000)을 하더라도 int와 달리 컴파일러가 에러를 띄우며 값을 걸러준다.

위에서 만든 Level 타입의 변수를 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 생략
}

DB의 USER 테이블에도 필드를 추가해보자.
필드명타입설정
LeveltinyintNot Null
LoginintNot Null
RecommendintNot Null

◼ UserDaoTest 수정

테스트가 성공하도록 UserDaoJdbc를 수정해보자.

public class UserDaoJdbc implements UserDao {
	...
    private RowMapper<User> userMapper =
    	new RowMapper<User>() {
        	public User mapRow(ResultSet rs, int rowNum) throws SQLException {
            	User user = new user();
                user.setId(rs.getString("id"));
                user.setName(rs.getString("name"));
                user.setPassword(rs.getString("password"));
                user.setLevel(Level.valueOf(rs.getInt("level")));
                user.setLogin(rs.getInt("loign"));
                user.setRecommend(rs.getInt("recommend"));
                return user;
            }
        };
        
    public void add(User user){
    	this.jdbcTemplate.update(
        "insert into users(id, name, password, level, login, recommend)" +
        "values(?,?,?,?,?,?)", user.getId(), user.getName(),
        user.getPassword(), user.getLevel().intValue(),
        user.getLogin(), user.getRecommend());
    }
}

// 실행 시, BadSqlGrammarException 예외 발생 
// - loign -> login으로 수정해야함 - 일부러 낸 오타
// 테스트를 DB까지 연동되는 테스트로 만들어두어 오타를 빠르게 잡아낼 수 있었음을 보여주기 위함
// 연동되어 있지 않았다면? 빌드, 서버 생성 및 배치에 시간을 낭비, 심각한 버그가 생길 가능성 ↑

📝 사용자 수정 기능 추가

수정할 정보가 담긴 User오브젝트를 전달하면 id를 참고하여 정보를 update하는 메소드를 작성해보자.

◼ 수정 기능 테스트 추가

테스트를 먼저 작성하고 이를 성공시키는 코드를 작성하도록하자. (TDD)

@Test
public void update() {
	dao.deleteAll();
    
    dao.add(user1);
    
    //정보를 변경하여 수정 메소드를 호출
    user1.setName("오민규");
    user1.setPassword("springno6");
    user1.setLevel(Level.GOLD);
    user1.setLogin(1000);
    user1.setRecommend(999);
    dao.update(user1);
    
    User user1update = dao.get(user1.getId());
    checkSameUser(user1, user1update);
}

UserDao와 UserDaoJdbc 수정
위의 테스트를 성공시키기 위해 update()메소드를 추가

//update()메소드가 없으므로 인터페이스 추가
public interface UserDao {
	...
    public void update(User user1);
}
// 인터페이스로 만든 update() 구현
public void update(User user){
	this.jdbcTemplate.update(
    		"update users set name = ?, password = ?, login = ?, " +
            "recommend = ? where id = ? ", user.getName(), user.getPassword(), 
            user.getLevel().intValue(), user.getLogin(), user.getRecommend(), 
            user.getId());
}

◼ 수정 테스트 보완

위의 테스트는 update()메소드에서 SQL문에 있는 where을 빼도 테스트가 성공한다
- 테스트에 결함이 있음

위의 문제를 해결하기 위해서는 원하는 사용자외의 정보는 변하지 않음을 확인하면 된다.
- 사용자를 두 명 등록해두고 하나만 수정한 뒤, 두 사용자의 정보를 모두 확인

@Test
public void update() {
	dao.deleteAll();
    
    dao.add(user1);
    dao.add(user2);
    
    user1.setName("오민규");
    user1.setPassword("springno6");
    user1.setLevel(Level.GOLD);
    user1.setLogin(1000);
    user1.setRecommend(999);
    
    dao.update(user1);
    
    User user1update = dao.get(user1.getId());
    checkSameUser(user1, user1update);
    User user2same = dao.get(user2.getId());
    checkSameUser(user2, user2same);
}

📝 UserService.upgradeLevels()

사용자 관리 로직을 두기 위해 새로운 클래스인 UserService를 생성해보자.

UserDao의 구현클래스가 바뀌어도 영향을 받지않도록 하기 위해 DAO의 인터페이스를 타입으로 하는 인스턴스 변수를 사용하고 DI를 적용시킴.

package springbook.user.service;
...
public class UserService {
	UserDao userDao; //인터페이스를 타입으로 하는 변수
    
    public void setUserDao(UserDao userDao) { // 수정자 메소드로 DI가 가능하도록 함
    	this.userDao = userDao;
    }
}

◼ UserServiceTest 테스트 클래스

UserService를 테스트하는 UserServiceTest 클래스를 작성해보자.

package springbook.user.service;
...
@RunWith(SpringJunit4ClassRunner.class)
@ContextConfiguration(location="/test-applicationContext.xml")
public class UserServiceTest {
	@Autowired
    UserService userservice;
}

◼ upgradeLevels() 메소드

사용자의 레벨을 관리하는 기능을 가진 메소드를 만들어보자
- 이번엔 로직을 먼저 작성 후 테스트를 작성할 것임!

public void upgradeLevels() {
	List<User> users = userDao.getAll();
    for(User user : users) {
    	Boolean changed = null;
        
        // 로그인 횟수가 50이상이면 SILVER로 승급
        if(user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
        	user.setLevel(Level.SILVER);
            changed = true;
        }
        // SILVER 등급에서 추천수가 30개 이상이면 GOLD로 승급
        else if(user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
        	user.setLevel(Level.GOLD);
            changed = true;
        }
        // GOLD 윗단계는 없음
        else if (user.getLevel() == Level.GOLD) { changed = false; }
        else { changed = false; }
        
        // 레벨의 변경이 있으면 update()호출
        if(changed) { userDao.update(user); }
    }
}

◼ upgradeLevels() 테스트

class UserServiceTest {
	...
    List<User> users;
    
    @Before
    public void setUp() {
    	users = Arrays.asList(
        		new User("bumjin", "박범진", "p1", Level.BASIC, 49, 0),
                new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0),
                new User("erwins", "신승한", "p3", Level.SILVER, 60, 29),
                new User("madnite1", "이상호", "p4", Level.SILVER, 60, 30),
                new User("green", "오민규", "p5", Level.GOLD, 100, 100)
        );
    }
    
    
    @Test
    public void upgradeLevels() {
    	userDao.deleteAll();
        for(User user : users) userDao.add(user);
        
        userService.upgradeLevels();
        
        // 사용자의 예상 레벨을 검증 - 모두 맞아야 테스트 통과
        checkLevel(users.get(0), Level.BASIC);
        checkLevel(users.get(1), Level.SILVER);
        checkLevel(users.get(2), Level.SILVER);
        checkLevel(users.get(3), Level.GOLD);
        checkLevel(users.get(4), Level.GOLD);
    }
    
    private void checkLevel(User user, Level expectedLevel) {
    	User userUpdate = userDao.get(user.getId());
        assertThat(userUpdate.getLevel(), is(expectedLevel));
    }
}

📝 UserService.add()

UserService에도 add()를 만들어 처음 가입한 생성자의 레벨을 BASIC으로 설정하도록 한다

이번에는 테스트를 먼저 작성해보자!
- 레벨이 비어있는 경우, 처음 가입한 사용자이므로 BASIC.
- 특별한 이유로 레벨이 채워져 있는 경우, 그대로 두기

@Test
public void add() {
	userDao.deleteAll();
    
    User userWithLevel = users.get(4); // GOLD 레벨
    
    // 레벨이 비어있는 사용자
    User userWithoutLevel = users.get(0); 
    userWithoutLevel.setLevel(null);
    
    userService.add(userWithLevel); //레벨이 정해져 있으면 그대로 두기
    userService.add(userWithoutLevel); //비어있으면 BASIC
    
    //DB에 저장된 결과를 가져와 확인
    User userWithLevelRead = userDao.get(userWithLevel.getId());
    User userWithoutLevelRead = userDao.get(userWithoutLevel.getId());
    
    //그대로 두기
    assertThat(userWithLevelRead.getLevel(), is(userWithLevel.getLevel()));
    //BASIC
    assertThat(userWithoutLevelRead.getLevel(), is(Level.BASIC)); 
}

테스트를 성공하도록 add()메소드를 작성해보자

public void add(User user) {
	if(user.getLevel() == null) user.setLevel(Level.BASIC);
    userDao.add(user);
}

📝 코드 개선

코드를 다음과 같은 질문을 하며 살펴보도록 하자!

  • 코드에 중복된 부분은 없는가?
  • 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?
  • 코드가 자신이 있어아 할 자리에 있는가?
  • 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?

◼ upgradeLevels() 메소드 코드의 문제점

if/elseif/else 블록들이 읽기가 불편하며, 나중에 업데이트를 할 때에도 힘듦.

   // 현재 레벨을 파악하는 로직       // 업그레이드 조건을 담은 로직
if(user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
 			// 다음 단계의 레벨이 무엇이며, 어떤 작업을 하는가
        	user.setLevel(Level.SILVER);
            // 그 자체로는 의미가 없음.
            changed = true;
}
...
// 이 작업을 위해 changed를 사용
if(changed) { userDao.update(user); }

◼ upgradeLevels() 리팩토링

upgradeLevels() 메소드의 흐름을 간단하게 보면 다음과 같이 정리할 수 있다.

public void upgradeLevels() {
	List<User> users = userDao.getAll();
    for(User user : users) {
    	if(canUpgradeLevel(user)) {
        	upgradeLevel(user);
        }
    }
}

이제 위의 코드의 메소드들을 하나씩 추가해 나가면 된다.
먼저, 업그레이드 가능 여부를 확인하는 canUpgradeLevels()를 만들어보자.

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);
    }
}

다음으로는 업그레이드를 수행하는 upgradeLevel(User user)메소드를 만들어보자.

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);
}

upgradeLevel(User user)의 문제점?

  • 예외상황에 대한 처리가 없음
  • 다음 단계가 무엇인가 하는 로직과 사용자 오브젝트의 level필드를 변경해준다는 로직이 함께 있음 - 단일 책임 원칙 위배

위의 문제를 해결하기위해 레벨의 순서와 다음 단계 레벨이 무엇인지 결정하는 일은 Level에게 맡김

package springbook.user.domain;
...
public enum Level {
	BASIC(1, SILVER), SILVER(2, GOLD), GOLD(3, null); // 다음 단계 레벨도 정의
    
    private final int value;
    private final Level next; //다음 단계 레벨정보를 스스로 가지고 있도록 함
    
    Level(int value, Level next) { // 생성자 만들기
    	this.value = value;
        this.next = next;
    }
    
    public int intValue() { // 값을 가져오는 메소드
    	return value;
    }
    
    public Level nextLevel() {
    	return this.next;
    }
    
    //값으로부터 Level 타입 오브젝트를 가져오도록 만든 스태틱 메소드
    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);
        }
    }
}

upgradeLevel(User user) 수정을 위해 upgradeLevel() 생성

public void upgradeLevel() {
	Level nextLevel = this.level.nextLevel();
    if(nextLevel == null)
    	throw nex IllegalStateException(this.level + "은 업그레이드가 불가능합니다.");
    else
    	this.level = nextLevel;
}

최종 upgradeLevel(User user) 작성

private void upgradeLevel(User user) {
	user.upgradeLevel();
    userDao.update(user);
}

◼ User 테스트

위에서 User에 작성한 upgradeLevel()을 테스트 해보자

package springbook.user.service;
...
public class UserTest {
	User user;
    
    @Before
    public void setUp() {
    	user = new User();
    }
    
    @Test()
    public void upgradeLevel() {
    	Level[] levels = Level.values();
        for(Level level : levels) {
        	if(level.nextLevel() == null) continue;
            user.setLevel(level);
            user.upgradeLevel();
            assertThat(user.getLevel(), is(level.nextLevel()));
        }
    }
    
    @Test(expected=IllegalStateException.class)
    public void cannotUpgradeLevel() {
    	Level[] levels = Level.values();
        for(Level level : levels) {
        	if(level.nextLevel() != null) continue;
            user.setLevel(level);
            user.upgradeLevel();
        }
    }
}

◼ UserServiceTest 개선

다음 레벨을 저장해두고 있으므로 다음 레벨을 굳이 전달할 필요가 없어짐
- 업그레이드 여부만 보도록 수정

@Test
public void upgradeLevels() {
  	userDao.deleteAll();
	for(User user : users) userDao.add(user);
        
	userService.upgradeLevels();
        
    // 사용자의 예상 레벨을 검증 - 모두 맞아야 테스트 통과
	//어떤 레벨로 바뀌냐 -> 다음 레벨로 업그레이드가 되는가? 를 지정
	checkLevel(users.get(0), false);
    checkLevel(users.get(1), true);
    checkLevel(users.get(2), false);
    checkLevel(users.get(3), true);
    checkLevel(users.get(4), false);
}

private void checkLevelUpgraded(User user, boolean upgraded) {
    User userUpdate = userDao.get(user.getId());
    if(upgraded) //업그레이드 한다면
    	assertThat(userUpdate.getLevel(), is(user.getLevel().nextLevel())); 
	else //업그레이드 하지 않는다면
    	assertThat(userUpdate.getLevel(), is(user.getLevel()));
}

코드에 나타나는 중복을 제거해보자

- case BASIC: return (user.getLogin() >= 50); - UserService
- new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0) - UserServiceTest
=> 업그레이드 조건인 50이 중복되고 있는 상태. 만일 조건을 바꾼다면 두 곳에서 수정이 필요할 것

// UserService 클래스
// 정수형 상수로 업그레이드 조건을 선언
public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
public static final MIN_RECCOMEND_FOR_GOLD = 30;

private boolean canUpgradeLevel(User user) {
	Level currentLevel = user.getLevel();
    // 레벨별로 조건 판단
    switch(currentLevel) {
    	// 50 -> MIN_LOGCOUNT_FOR_SILVER로 변경
    	case BASIC: return (user.getLogin() >= MIN_LOGCOUNT_FOR_SILVER);
        // 30 -> MIN_RECCOMEND_FOR_GOLD로 변경
        case SILVER: return (user.getRecommend() >= MIN_RECCOMEND_FOR_GOLD);
        case GOLD: return false;
        // 다룰 수 없는 레벨이 주어지면 예외 발생.
        default: throw new IllegalArgumentException("Unknown Level: " +
        	currentLevel);
    }
}
// UserServiceTest 클래스
// UserService에서 선언한 상수 받아오기
import static soringbook.user.service.UserService.MIN_LOGCOUNT_FOR_SILVER;
import static soringbook.user.service.UserService.MIN_RECCOMEND_FOR_GOLD;
...
@Before
public void setUp() {
    users = Arrays.asList(
    	new User("bumjin", "박범진", "p1", Level.BASIC, MIN_LOGCOUNT_FOR_SILVER-1, 0),
    	new User("joytouch", "강명성", "p2", Level.BASIC, MIN_LOGCOUNT_FOR_SILVER, 0),
    	new User("erwins", "신승한", "p3", Level.SILVER, 60, MIN_RECCOMEND_FOR_GOLD-1),
    	new User("madnite1", "이상호", "p4", Level.SILVER, 60, MIN_RECCOMEND_FOR_GOLD),
   	 	new User("green", "오민규", "p5", Level.GOLD, 100, Integer.MAX_VALUE)
    );
}

📖 트랜잭션 서비스 추상화

만일 업그레이드 진행중 에러가 발생한다면 그전에 업그레이드가 완료된 사용자들을 어떻게 할까요? - 여기서는 모두 초기상태로 되돌리기로 결정!

📝 모 아니면 도

에러가 발생하는 상황강제로 만들어야 하므로 예외를 억지로 발생시켜야한다
- 이는 기존의 코드 수정보단 테스트를 위한 클래스를 새로 만드는 방법이 좋다
- 코드의 중복을 피하기 위해 기존의 코드를 상속받아서 오버라이딩하도록 하자!

// UserService 의 upgradeLevel()메소드를 오버라이딩해야하므로 접근자 변경
protected void upgradeLevel(User user) { ... }
// 테스트 대역 작성
static class TestUserService extends UserService {
	private String id;
    
    private TestUserService(String id) { // 예외를 발생시킬 id를 지정
    	this.id = id;
    }
    
    protected void upgradeLevel(User user) { //UserService 메소드를 오버라이딩
    	// 지정된 id의 User 오브젝트가 발견되면 예외던지기
    	if(user.getId().equals(this.id)) throw new TestUserServiceException(); 
        super.upgradeLevel(user);
    }
}

//테스트에 사용할 예외 정의
static class TestUserServiceException extends RuntimeException {
}

◼ 강제 예외 발생을 위한 테스트

@Test
public void upgradeAllOrNothing() {
	//예외를 발생시킬 4번째 사용자의 id를 넣기
	UserServicee testUserService = new TestUserService(users.get(3).getId());
    testUserService.setUserDao(this.userDao); //userDao 수동 DI
    
    userDao.deleteAll();
    for(User user : users) userDao.add(user);
    
    try{
    	testUserService.upgradeLevels();
        fail("TestUSerServiceException expected");
    }
    catch(TestUSerServiceException e) { //해당 예외가 맞다면 그대로 진행
    }
    
    checkLevelUpgraded(users.get(1), false); //예외가 발생하기전, 후가 같은지 확인
}

// 테스트가 실패할 것이다
// 두번째 사용자의 레벨이 BASIC에서 SILVER로 바뀐것이 예외가 발생했음에도 그대로 유지됨

◼ 테스트 실패의 원인

upgradeLevels()메소드가 하나의 트랜젝션 안에서 동작하지 않았기때문
- 하나의 트랜젝션안에 있어야 예외로 인해 되돌아갈 때(롤백) 모두 초기상태가 됨.

▼ 트랜젝션이 3개 생성됨

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

하나의 트랜젝션에 위 과정을 모두 넣으면서 지금까지 만든 UserService와 UserDao를 그대로 둔 채로 트랜젝션을 적용하기 위해서 경계설정 작업을 UserService로 가져와야 한다.

//upgradeLevels의 트랜잭션 경계설정 구조
public void upgradeLevels() throws Exception {
	1) DB Conncetion 생성
    2) 트랜잭션 시작
    try {
    	3) DAO 메소드 호출
        4) 트랜잭션 커밋
    }
    catch(Exception e) {
    	5) 트랜잭션 롤백
        throw e;
    }
    finally {
    	6) DB Connection 종료
    }
}
//Connection 오브젝트를 UserDao에서 사용하기 위해 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);
}
//Connection을 공유하도록 수정한 UserService메소드
class UserService {
	public void upgradeLevels() throws Exception {
    	Connection c = ...; //Connection 생성
        ...
        try{
        	...
            upgradeLevel(c,user); //DAO 메소드 호출
            ... // 트랜잭션 커밋
        }
        ... // 트랜잭션 롤백
    } // 트랜잭션 종료
    
    protected void upgradeLevel(Connection c, User user) {
    	user.upgradeLevel();
        userDao.update(c,user);
    }
}

◼UserService 트랜잭션 경계설정의 문제점

  • JdbcTemplate을 더 이상 활용할 수 없다
    - 따라서 원래 사용했던 try/catch/finally문을 사용해야 한다
  • UserService의 메소드와 DAO의 메소드에 Connection 파라미터가 추가되어야 함
    - UserService가 스프링 빈이라서 Connection을 다른 메소드에 전달할 수 없음
    - 스프링 빈은 싱글톤이다!
  • Connection 파라미터가 추가되면 UserDao는 데이터 액세스 기술에 독립적일 수가 없다
    - JPA, Hibernate로 구현 방식을 변경하려면 Connection이 아닌 다른 오브젝트를 사용해야함
    - Connection이 들어간 모든 코드를 수정해야함

📝 트랜잭션 동기화

스프링은 위 문제를 해결할 수 있는 멋진 방법을 제공해줌

◼ Connection 파라미터 제거

먼저 Connection을 직접 전달해야 하는 문제를 해결해보자!


upgradeLevels()가 경계설정을 해야되는 사실은 피할 수 없음
하지만 DAO를 호출할 때 Connection을 전달하지 않아도 된다면 제거가 가능
=> 스프링은 트랜잭션 동기화를 통해 이 문제를 해결

❓ 트랜잭션 동기화란

트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관
이후 호출되는 DAO의 메소드에서는 저장된 Connection을 가져다 쓰도록 함

▼ 동기화 방식을 적용한 UserService의 작업흐름

트랜잭션 동기화 방식을 적용한 UserService 코드

private DataSource dataSource;

public void setDataSource(DataSource dataSource) {
	this.dataSource = dataSource;
}

public void upgradeLevels() throws Exception {
	//트랜잭션 동기화 관리자를 이용해 동기화 작업 초기화
	TransactionSynchronizationManager.initSynchronization(); 
    //DB 커넥션을 생성하고 트랜잭션 시작. 이후 작업들은 모두 이 트랜잭션에서 진행
    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 {
    	// 스프링 유틸리티 메소드를 이용해 DB 커넥션을 안전하게 닫는다
    	DataSourceUtils.releaseConnection(c, dataSource);
 		
        //동기화 작업 종료 및 정리      
        TransactionSynchronizationManager.unbindResource(this.dataSource);
        TransactionSynchronizationManager.clearSynchronization(); 
    }
}

◼ 트랜잭션 테스트 보완

동기화가 적용된 UserService 테스트를 위해 TestUserService를 수정해보자

@Autowired DataSource dataSource;
...
@Test
public void upgradeAllOrNothing() throws Exception {
	UserService testUserService = new TestUserService(users.get(3).getId());
    testUserService.setUserDao(this.userDao);
    testUserService.setDataSource(this.dataSource);
	...

◼ JdbcTemplate와 트랜잭션 동기화

JdbcTemplate는 영리하게 동작함
- DB커넥션이나 트랜잭션이 없는 경우 - 직접 생성
- 트랜잭션 동기화를 시작해놓은 상태 - 저장소에 저장된 DB커넥션을 가져와 사용

📝 트랜잭션 서비스 추상화

◼ 기술과 환경에 종속되는 트랜잭션 경계설정 코드

DB의 연결 방법이 바뀌는 경우 - UserDao, UserService코드 수정하지 않아도 됨
하나의 트랜잭션에서 여러개의 DB사용 - Connection(로컬 트랜잭션)으로는 불가능

  • 글로벌 트랜잭션

    별도의 트랜잭션 관리자를 통해 트랜잭션을 관리
    - 글로벌 트랜잭션을 지원하는 트랜잭션 매니저사용

  • JTA(Java Transaction API)

    자바트랜잭션 매니저를 지원하는 API JTA(Java Transaction API) 제공

JTA를 이용한 트랜잭션 코드 구조

//JNDI를 이용해 서버의 UserTransaction 오브젝트를 가져옴
InitialContext ctx = new InitialContext();
UserTransaction tx = (UserTransaction)ctx.lookup(USER_TX_JNDI_NAME);

tx.begin();
Connection c = dataSource.getConnection(); - JNDI로 가져온 dataSource를 사용
try {
	// 데이터 액세스 코드
    tx.commit();
} catch (Exception e) {
	tx.rollback();
    throw e;
} finally{
	c.close();
}
  • 트랜잭션 경계설정 코드의 문제점
    • JDBC 로컬 트랜잭션JTA를 이용하는 글로벌 트랜잭션으로 바꾸려면 UserService의 코드를 수정해야 한다
    • DB연결방식이 달라지면 트랜잭션 경계설정코드를 변경해야 함

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

  • 문제점

    트랜잭션 경계설정 코드를 추가하면서 특정 액세스 기술에 종속되는 구조가 됨

  • 해결책

    독립적인 구조로 바꾸기위해 트랜잭션 경계설정 코드를 추상화시킨다!
    - 트랜잭션 경계설정 코드는 DB마다 코드는 다르지만 일정한 패턴을 갖는 유사한 구조이므로 사용방법을 추상화하여 사용할 수 있다

◼ 스프링의 트랜잭션 서비스 추상화

스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공한다
- 이를 이용하면 일관된 방식으로 트랜잭션을 제어가능

스프링의 트랜잭션 추상화 API를 적용한 upgradeLevels()

public void upgradeLevels() {
	//JDBC 트랜잭션 추상 오브젝트 생성
	PlatformTransactionManager transactionManager =
    				new DataSourceTransactionManager(dataSource);
    //트랜잭션 시작
    TransactionStatus status =
    	transactionManager.getTransaction(new DefaultTransactionDefinition());
    
    //트랜잭션 진행중//    
    try { 
    	List<User> users = userDao.getAll();
        for(User user : users) {
        	if (canUpgradeLevel(user)) {
            	upgradeLevel(user);
            }
        }
    ////////////////
    
        transactionManager.commit(status); //트랜잭션 커밋
    } catch (RuntimeException e) {
    	transactionManager.rollback(status); //트랜잭션 커밋
        throw e;
    }
}

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

UserService 클래스가 구체적인 트랜잭션 매니저를 알고있으면 DI원칙이 위배
- 따라서 컨테이너를 통해 외부에서 제공받게 하는 스프링 DI 방식으로 변경
- 스프링이 제공하는 PlatformTransactionManager싱글톤으로 사용가능

pubilc class UserService {
	...
    private PlatformTransactionManager transactionManager; ///DI받을 변수 생성
    
    public void setTransactionManager(PlatformTransactionManager 
    		transactionManager) {
    	this.transactionManager = transactionManager;        
    }
    
    public void upgradeLevels() {
        //트랜잭션 시작
        TransactionStatus status =
            this.transactionManager.getTransaction(new 
            	DefaultTransactionDefinition()); //DI받은 매니저 공유해서 사용

        //트랜잭션 진행중//    
        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;
        }
    }
    ...

코드가 바뀌었으니 테스트도 수정해주자

@Autowired PlatformTransactionManager transactionManager;
...
@Test
public void upgradeAllOrNothing() throws Exception {
	UserService testUserService = new TestUserService(users.get(3).getId());
    testUserService.setUserDao(this.userDao);
    testUserService.setTransactionManager(transactionManager); // 수동 DI
	...

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

📝 수직, 수평 계층구조와 의존관계

  • 같은 애플리케이션 로직을 담은 코드이지만 내용에 따라 분리 - 수평적인 분리
    - UserDao와 UserService는 애플리케이션 코드이지만 내용에 따라 분리된 코드
    - 두 클래스간의 결합도가 낮아 독립적으로 확장 가능
  • 트랜잭션의 추상화 코드 - 수직적인 분리
    - 애플리케이션의 비즈니스 로직과 로우레벨의 트랜잭션 기술이 서로 분리
    - 두 계층간 결합도가 낮아 기술이 변경되더라도 애플리케이션 코드는 영향을 받지않음

📝 단일 책임 원칙

위와 같은 적절한 분리가 가져오는 특징 - 단일 책임 원칙

단일 책임 원칙(SRP)이란?

하나의 모듈한가지 책임을 가져야 함.
- 모듈이 수정되는 이유는 한 가지여야 한다.

UserService가 Connection을 직접 사용하던 경우

  • 두가지의 책임이 존재했음
  1. 레벨 업그레이드와 관련된 로직이 변경되는 경우
  2. UserTransaction을 사용하는 JTA로 변경하는 경우
    => 단일 책임 원칙을 위배

◼ 단일 책임 원칙의 장점

  • 수정 대상이 명확해짐
    - 기술이 변경되는 경우 기술 코드만 변경, 비즈니스 로직 변경 시 애플리케이션 코드만 수정하면 됨

❗ 로직과 기술을 분리하는 핵심 도구 : DI

앞에서 했던 코드 분리의 핵심적인 도구는 스프링의 DI
- 인터페이스로 추상화를 하더라도 DI가 없다면 결함이 존재하게된다.
  - PlatformTransactionManager를 DI받음으로써, 트랜잭션 코드를 분리할 수 있었음

📖 메일 서비스 추상화

레벨이 업그레이드되는 사용자에게는 안내 메일을 발송해달라는 요청사항이 들어옴

📝JavaMail을 이용한 메일 발송 기능

자바에서 메일을 발송할 때는 표준 기술인 JavaMail사용

//레벨 업그레이드 작업 메소드 수정

protected void upgradeLevel(User user) {
	user.upgradeLevel();
    userDao.update(user);
    sendUpgradeEMail(user);
}
//JavaMail을 이용한 메일 발송 메소드 작성
private void sendUpgradeEMail(User user) {
	Properties props = new Properties();
    props.put("mail.smtp.host", "mail.ksug.org");
    Session s = Session.getInstance(props, null);
    
    MimeMessage message = new MimeMessage(s);
    try{
    	message.setFrom(new InternetAddress("useradmin@ksug.org"));
        message.addRecipient(Message.RecipientType.TO,
        						new InternetAddress(user.getEmail()));
        message.setSubject("Upgrade 안내");
        message.setText("사용자님의 등급이 " + user.getLevel().name() +
        	"로 업그레이드되었습니다.");
            
        Transport.send(message); 
    } catch(AddressException e) {
    	throw new RuntimeException(e);
    } catch (MessagingException e) {
    	throw new RuntimeException(e);
    } catch (UnsupportedEncodingException e) {
    	throw new RuntimeException(e);
    }
}

📝 JavaMail이 포함된 코드의 테스트

테스트할때마다 메일 서버를 준비하는 것은 운영중인 메일서버에 부하를 준다
- 메일서버는 충분히 테스트된 시스템이기 때문에 전송 요청을 받으면 메일이 전송된다고 신뢰할 수 있다.
-> 테스트용 메일서버를 만들어서 전송 요청까지만 받도록함


메일 서버를 믿을 수 있다면 같은 원리로 JavaMail도 믿을 수 있지 않을까?
=> JavaMail API를 통해 요청이 들어간다는 보장만 있으면 구동이 필요없음

📝 테스트를 위한 서비스 추상화

JavaMail을 이용한 테스트의 문제점

JavaMail의 API는 위의 방법을 적용할 수 없음
- JavaMail의 핵심 API에는 인터페이스로 만들어서 구현을 바꿀 수 있는게 없다!
=> 서비스 추상화를 적용하면 된다

스프링이 제공하는 JavaMail의 추상화 인터페이스 - MailSender

package org.springframework.mail;
...
public interface MailSender {
	void send(SimpleMailMessage siplemMessage) throws MailException;
    void send(SimpleMailMessage[] simpleMessages) throws MailException;
}

스프링의 MailSender를 이용한 메일 발송 메소드

private void sendUpgradeEMail(User user) {
	JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
    mailSender.setHost("mail.server.com");
    
    //메일 내용 작성
    SimpleMailMessage mailMessage = new SimpleMailMessage();
    mailMessage.setTo(user.getEmail());
	mailMessage.setFrom("useradmin@ksug.org");
	mailMessage.setSubject("Upgrade 안내");
    mailMessage.setText("사용자님의 등급이 " + user.getLevel().name());
    
    mailSender.send(mailMessage);
}

테스트를 위해 테스트용 메일 전송 클래스를 만들어보자. MailSender를 구현하면 된다.

package springbook.user.service;
...
public class DummyMailSender implements MailSender {
	//메일을 전송할 필요가 없으니 빈 껍데기만 만들자
	public void send(SimpleMailMessage mailMessage) throws MailException {
    }
    
    public void send(SimpleMailMessage[] simpleMessages) throws MailException{
    }
}
public class UserServiceTest {
	...
    @Autowired
    MailSender mailSender; // 수동 DI
    
    @Test
    public void upgradeAllOrNothing() throws Exception {
    	...
        testUserService.setMailSender(mailSender);

📝 테스트 대역(test double)

◼ 의존 오브젝트의 변경을 통한 테스트 방법

테스트환경에서는 운영 DB연결, WAS의 DB 풀링 서비스의 사용도 번거로운 짐
- 테스트의 핵심해당 기능이 제대로 동작하는가? 이기 때문

UserService의 구조를 보면 테스트 시에는 메일 관련 구조를 그대로 사용하면 손해
따라서, DummyMailSender를 사용하는 것

  • 의존 오브젝트 / 협력 오브젝트(collaborator)

    하나의 오브젝트가 사용하는 오브젝트
    - 작은 기능이라도 다른 오브젝트의 기능을 사용한다면 의존하고 있다고 말함

◼ 테스트 대역의 종류와 특징

테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 실행가능할 수 있도록 사용하는 오브젝트

  • 테스트 스텁(test stub) : 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트가 정상적으로 수행되도록 돕는 것
    - 위에서 만든 DummyMailSender
  • 목 오브젝트(mock object) : 테스트 대상 오브젝트와 의존 오브젝트 사이에 일어나는 일을 검증할 수 있도록 특별히 설계된 오브젝트

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

목 오브젝트를 이용해 테스트를 수행해보자

목 오브젝트로 만든 메일 전송 확인용 클래스

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 {
    }
}

메일 발송 대상을 확인하는 테스트

@Test
@DirtiesContext

public void upgradeLevels() throws Exception {
	userDao.deleteAll();
    for(User user : users) userDao.add(user);
    
    MockMailSender mockMailSender = new MockMailSender();
    userService.setMailSender(mockMailSender);
    
    userService.upgradeLevels();
    
    checkLevelUpgraded(users.get(0), false);
    checkLevelUpgraded(users.get(1), true);
    checkLevelUpgraded(users.get(2), false);
    checkLevelUpgraded(users.get(3), true);
    checkLevelUpgraded(users.get(4), false);
    
    //목 오브젝트에 저장된 메일 수신자 목록을 가져와 업그레이드 대상과 일치하는지 확인
    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()));
}

❗ 더욱 상세한 내용을 알고싶으시다면 책을 구매하시는 것을 추천드립니다.

profile
모르는 것 정리하기

0개의 댓글