토비의 스프링 Vol1. 5 - 서비스 추상화

Seongmin·2023년 8월 21일

토비의 스프링

목록 보기
2/2

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

5.1.1 필드 추가

Level 이늄

public enum Level {
	BASIC(1), SILVER(2), GOLD(3);

	private final int value;
		
	Level(int value) {
		this.value = value;
	}

	public int intValue() {
		return 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);
		}
	}
}

User 필드 추가


public class User {
	String id;
	String name;
	String password;
	Level level;
	int login;
	int recommend;
	
	public User() {}
	
	public User(String id, String name, String password, Level level,
			int login, int recommend) {
		this.id = id;
		this.name = name;
		this.password = password;
		this.level = level;
		this.login = login;
		this.recommend = recommend;
	}
	...
}

UserDaoTest 테스트 수정

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class UserDaoTest {
	...
	@Before
	public void setUp() {
		this.user1 = new User("gyumee", "박성철", "springno1", Level.BASIC, 1, 0);
		this.user2 = new User("leegw700", "이길원", "springno2", Level.SILVER, 55, 10);
		this.user3 = new User("bumjin", "박범진", "springno3", Level.GOLD, 100, 40);
	}

UserDaoJdbc 수정

public class UserDaoJdbc implements UserDao {
	public void setDataSource(DataSource dataSource) {
		this.jdbcTemplate = new JdbcTemplate(dataSource);
	}
	
	private JdbcTemplate jdbcTemplate;
	
	private RowMapper<User> userMapper = 
		new RowMapper<User>() {
				public User mapRow(ResultSet rs, int rowNum) throws SQLException {
				User user = new User();
                ...
				user.setLevel(Level.valueOf(rs.getInt("level")));
				user.setLogin(rs.getInt("login"));
				user.setRecommend(rs.getInt("recommend"));
				return user;
			}
		};
...

5.1.2 사용자 수정 기능 추가

수정 기능 테스트 추가

public class UserDaoTest {
	...
	@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 수정

public interface UserDao {
	...
    public void update(User user1);
}
public void update(User user) {
	this.jdbcTemplate.update(
    	"update users set name = ?, password = ?, level = ?, login = ?, " +
        "recommend = ? where id = ? ", user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend(), user.getId());
}

수정 테스트 보완

update() 테스트는 소중하지 않아야 할 로우의 내용이 그대로 남아 있는가?

방법 1. JdbcTemplateupdate()가 돌려주는 리턴 값을 확인
방법 2. 사용자를 두 명 등록해놓고, 그중 하나만 수정한 뒤에 수정된 사용자와 수정하지 않은 사용자의 정보 모두 확인

public class UserDaoTest {
	...
	@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);
	}

5.1.3 UserService.upgradeLevels()

UserService 클래스와 빈 등록

public class UserService {
	UserDao userDao;
    
    public void setUserDao(UserDao userDao) {
    	this.userDao = userDao;
    }
}
<bean id="userService" class="springbook.user.service.UserService">
  <property name="userDao" ref="userDao" />
</bean>

<bean id="userDao" class="springbook.user.dao.UserDaoJdbc">
  <property name="dataSource" ref="dataSource" />
</bean>

UserServiceTest 테스트 클래스

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class UserServiceTest {
	@Autowired
    UserService userVice;
    
    @Test
    public void bean() {
    	assertThat(this.userService, is(notNullValue()));
    }
}

upgradeLevels() 메소드

public class UserService {
	public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
	public static final int MIN_RECCOMEND_FOR_GOLD = 30;
	public void upgradeLevels() {
		List<User> users = userDao.getAll();  
		for(User user : users) {  
			if (canUpgradeLevel(user)) {  
				upgradeLevel(user);  
			}
		}
	}
    
	private boolean canUpgradeLevel(User user) {
		Level currentLevel = user.getLevel(); 
		switch(currentLevel) {                                   
		case BASIC: return (user.getLogin() >= MIN_LOGCOUNT_FOR_SILVER); 
		case SILVER: return (user.getRecommend() >= MIN_RECCOMEND_FOR_GOLD);
		case GOLD: return false;
		default: throw new IllegalArgumentException("Unknown Level: " + currentLevel); 
		}
	}

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

upgradeLevels() 테스트

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class UserServiceTest {
	@Autowired 	UserService userService;	
	@Autowired UserDao userDao;
	
	List<User> users;	// test fixture
	
	@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)
				);
	}

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

5.1.4 UserService.add()

사용자가 기본적으로 BASIC 레벨인 것은 어디에서 구현할까?

  • UserDaoJdbc는 주어진 User 오브젝트를 DB에 정보를 넣고 읽는 방법에만 관심
  • User 클래스에서 초기화하는 것은, 처음 클래스를 초기화할 때 빼고는 무의미한 정보

UserServiceadd()에 로직을 구현

우선 테스트코드부터 구현

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class UserServiceTest {
	...
	@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);
		
		User userWithLevelRead = userDao.get(userWithLevel.getId());
		User userWithoutLevelRead = userDao.get(userWithoutLevel.getId());
		
		assertThat(userWithLevelRead.getLevel(), is(userWithLevel.getLevel())); 
		assertThat(userWithoutLevelRead.getLevel(), is(Level.BASIC));
	}
public void add(User user) {
	if(user.getLevel() == null) user.setLevel(Level.BASIC);
    userDao.add(user);
}

5.1.5 코드 개선

upgradeLevels() 리팩토링

기본 작업 흐름만 남겨둔 upgradeLevels()

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

업그레이드 가능 확인 메소드

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 {
	GOLD(3, null), SILVER(2, GOLD), BASIC(1, SILVER);
    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;
	}
	
	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);
		}
	}
}

User에게 레벨 업그레이드 기능 추가

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

Service 단에서 위를 활용

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

User 테스트

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 개선

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class UserServiceTest {
	...
	@Test
	public void upgradeLevels() {
		userDao.deleteAll();
		for(User user : users) userDao.add(user);
		
		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);
	}

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

상수의 도입

public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_FOR_GOLD= 30;

업그레이드 정책을 유연하게 변경할 수 있도록 인터페이스로 분리

public interface UserLevelUpgradePolicy {
	boolean canUpgradeLevel(User user);
    void upgradeLevel(User user);
}

5.2 트랜잭션 서비스 추상화

5.2.1 모 아니면 도

장애가 발생했을 때(예외 발생)를 의도적으로 구현해보자.

테스트용 UserService 대역

UserService를 확장하여, 미리 지정된 id의 유저가 Exception을 발생시키는 테스트용 클래스를 UserServiceTest 내부에 static으로 생성

UserService의 upgradeLevel() 메소드의 접근 권한을 protected로 변경

static class TestUserService extends UserService {
	private String id;
    
    private TestUserService(String id) {
    	this.id = id;
    }
    
    protected void upgradeLevel(User user) {
    	if (user.getId().equals(this.id)) throw new TestUserServiceException();
        super.upgradeLevel(user);
    }
}

static class TestUserServiceException extends RuntimeException {}

강제 예외 발생을 통한 테스트

@Test
public void upgradeAllOrNothing() throws Exception {
	UserService testUserService = new TestUserService(users.get(3).getId());  
	testUserService.setUserDao(this.userDao); 
	testUserService.setDataSource(this.dataSource);
		
	userDao.deleteAll();			  
	for(User user : users) userDao.add(user);
	
	try {
		testUserService.upgradeLevels();   
		fail("TestUserServiceException expected"); 
	}
	catch(TestUserServiceException e) { 
	}
	
	checkLevelUpgraded(users.get(1), false);
}

위는 AssertionError를 발생시킨다.

테스트 실패의 원인

트랜잭션의 문제

5.2.2 트랜잭션 경계설정

DB는 그 자체로 완벽한 트랜잭션을 지원한다.

트랜잭션 롤백: 여러 개의 SQL을 수행 중 문제가 발생하는 경우 앞선 SQL 작업도 모두 취소하는 작업
트랜잭션 커밋: 모든 SQL 수행 작업이 성공적으로 마무리됐다고 DB에 알려 작업을 확정시키는 것

JDBC 트랜잭션의 트랜잭션 경계설정

모든 트랜잭션은 시작지점과 끝지점이 있다. 시작 방법은 한 가지이지만, 끝나는 방법은 커밋과 롤백 두 가지이다.

Connection c = dataSource.getConnection();

c.setAutoCommit(false);
try {
	PreparedStatement st1 = c.prepareStatement("update users ...");
    st1.executeUpdate();
    
    PreparedStatement st2 = c.prepareStatement("delete users ...");
    st2.executeUpdate();
    
    c.commit();
} catch(Exception e) {
	c.rollback();
}

c.close();

트랜잭션의 경계설정: setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하는 작업

로컬 트랜잭션: 하나의 DB 커넥션 안에서 만들어지는 트랜잭션

UserService와 UserDao의 트랜잭션 문제

트랜잭션엔 Connection 오브젝트가 사용되는데, JdbcTemplate는 템플릿 메소드 호출마다 한 개의 Connection이 만들어지고 닫히므로, JdbcTemplate의 메소드를 사용하는 UserDao는 각 메소드마다 하나씩의 독립적인 트랜잭션으로 실행될 수밖에 없다.

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

public void upgradeLevels() throws Exception {
	(1) DB Connection 생성
    (2) 트랜잭션 시작
    try {
    	(3) DAO 메소드 호출
        (4) 트랜잭션 커밋
    }
    catch(Exception e) {
    	(5) 트랜잭션 롤백
        throw e;
    }
    finally {
    	(6) DB Connection 종료
    }
}

UserService에서 만든 Connection 오브젝트를 UserDao에서 사용하게 해야한다.

public interface UserDao {
	public void add(Connection c, User user);
    public User get(Connection c, String id);
    ...
    public void update(Connection c, User user1);
}

UserService 내에서도 upgradeLevel() 메소드가 같은 Connection 오브젝트를 사용하도록 파라미터로 전달

class UserService {
	public void upgradeLevels() throws Exception {
		Connection c = ...;
		...
		try {									   
			...
			upgradeLevel(user);
			...
    }
    
    protected void upgradeLevel(Connection c, User user) {
    	user.upgradeLevel();
        userDao.update(c, user);
    }
}

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

문제점 1. JdbcTemplate를 활용할 수 없다.
문제점 2. UserService의 메소드에 Connection 파라미터가 추가돼야 한다.
문제점 3. Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면UserDao는 더이상 데이터 액세스 기술에 독립적일 수 없다.
문제점 4. 테스트 코드에 영향을 미친다.

5.2.3 트랜잭션 동기화

Connection 파라미터 제거

트랜잭션 동기화: Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서 저장된 Connection을 가져다가 사용
JdbcTemplate이 트랜잭션 동기화 방식을 이용

트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌이 날 염려는 없다.

트랜잭션 동기화 적용

public class UserService {
	private DataSource dataSource;  			

	public void setDataSource(DataSource dataSource) {
		this.dataSource = dataSource;
	}
    
	public void upgradeLevels() throws Exception {
		TransactionSynchronizationManager.initSynchronization();  
		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 {
			DataSourceUtils.releaseConnection(c, dataSource);	
			TransactionSynchronizationManager.unbindResource(this.dataSource);  
			TransactionSynchronizationManager.clearSynchronization();  
		}
	}
    ...
}

트랜잭션 테스트 보완

TestUserService 또한 DataSource를 DI

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class UserServiceTest {
	...
	@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);
		 
		userDao.deleteAll();			  
		for(User user : users) userDao.add(user);
		
		try {
			testUserService.upgradeLevels();   
			fail("TestUserServiceException expected"); 
		}
		catch(TestUserServiceException e) { 
		}
		
		checkLevelUpgraded(users.get(1), false);
	}
    ...
}
<bean id="userService" class="springbook.user.service.UserService">
  	<property name="userDao" ref="userDao" />
	<property name="dataSource" ref="dataSource" />
</bean>

UserService와 UserDao의 트랜잭션 문제

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

SQL이나 데이터 액세스 코드는 최대한 그대로 남겨둔 채로, UserService에 트랜잭션 시작과 종료를 담당하는 최소한의 코드만 가져오게 만들어 책임을 분리할 수 있다.

UserService에서 트랜잭션을 담당해야 하므로, 트랜잭션과 관련된 Connection 오브젝트는 UserService로 가져와야 한다. UserService에서 만든 Connection 오브젝트를 UserDao로 넘겨준다.

public interface UserDao {
	public void add(Connection c, User user);
    public void update(Connection c, User user);
    ...
}
class UserService {
	public void upgradeLevels() throws Expetion {
    	Connection c = ...;
        ...
        try {
        	...
            upgradeLevel(c, user);
            ...
        }
        ...
    }
    
    protected void upgradeLevel(Connection c, User user) {
    	user.upgradeLevel();
        userDao.update(c, user);
    }
}

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

  1. JdbcTemplate을 더 이상 활용할 수 없다.
    try/catch 블록이 UserService 내에 다시 존재한다.
  2. UserService 메소드에 Connection 파라미터가 추가된다.
    DAO에 도달하기까지 모든 메소드에 걸쳐 Connection 오브젝트가 계속 전달돼야 함
  3. UserDao는 더 이상 데이터 액세스 기술에 독립적일 수 없다.
    JPA 스펙을 따를 땐, Connection 대신 EntityManager 등을 활용해야 하므로 DAO 분리가 안됨
  4. 테스트 코드에 영향을 미친다.

5.2.3 트랜잭션 동기화

Connection 파라미터 제거

트랜잭션 동기화(transaction synchronization): UserSerivce에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO 메소드에서는 저장된 Connection을 가져다가 사용하게 하는 것

트랜잭션 동기화 저장소에UserService에서 만든 Connection 오브젝트를 저장한다.

트랜잭션 동기화 적용

private DataSource dataSource;

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

public void upgradeLevels() throws Exception {
	TransactionSynchronizationManager.initSynchronization();
    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 {
    	DataSourceUtils.releaseConnection(c, dataSource);
        TransactionSynchronizationManager.unbindResource(this.dataSource);
        TransactionSynchronizationManager.clearSynchronization();
    }
}

JdbcTemplateTransactionSynchronizationManager에서 관리하는 Connection 오브젝트를 사용한다.

트랜잭션 테스트 보완

동기화 테스트

@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과 트랜잭션 동기화

0개의 댓글