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);
}
}
}
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;
}
...
}
@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);
}
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;
}
};
...
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);
}
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. JdbcTemplate의 update()가 돌려주는 리턴 값을 확인
방법 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);
}
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>
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class UserServiceTest {
@Autowired
UserService userVice;
@Test
public void bean() {
assertThat(this.userService, is(notNullValue()));
}
}
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);
}
...
@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));
}
...
사용자가 기본적으로 BASIC 레벨인 것은 어디에서 구현할까?
UserDaoJdbc는 주어진 User 오브젝트를 DB에 정보를 넣고 읽는 방법에만 관심User 클래스에서 초기화하는 것은, 처음 클래스를 초기화할 때 빼고는 무의미한 정보→ UserService의 add()에 로직을 구현
우선 테스트코드부터 구현
@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);
}
기본 작업 흐름만 남겨둔 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);
}
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();
}
}
}
@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);
}
장애가 발생했을 때(예외 발생)를 의도적으로 구현해보자.
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를 발생시킨다.
트랜잭션의 문제
DB는 그 자체로 완벽한 트랜잭션을 지원한다.
트랜잭션 롤백: 여러 개의 SQL을 수행 중 문제가 발생하는 경우 앞선 SQL 작업도 모두 취소하는 작업
트랜잭션 커밋: 모든 SQL 수행 작업이 성공적으로 마무리됐다고 DB에 알려 작업을 확정시키는 것
모든 트랜잭션은 시작지점과 끝지점이 있다. 시작 방법은 한 가지이지만, 끝나는 방법은 커밋과 롤백 두 가지이다.
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 커넥션 안에서 만들어지는 트랜잭션
트랜잭션엔 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);
}
}
문제점 1. JdbcTemplate를 활용할 수 없다.
문제점 2. UserService의 메소드에 Connection 파라미터가 추가돼야 한다.
문제점 3. Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면UserDao는 더이상 데이터 액세스 기술에 독립적일 수 없다.
문제점 4. 테스트 코드에 영향을 미친다.
트랜잭션 동기화: 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>

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 내에 다시 존재한다.UserService 메소드에 Connection 파라미터가 추가된다.Connection 오브젝트가 계속 전달돼야 함UserDao는 더 이상 데이터 액세스 기술에 독립적일 수 없다.Connection 대신 EntityManager 등을 활용해야 하므로 DAO 분리가 안됨트랜잭션 동기화(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();
}
}
JdbcTemplate은 TransactionSynchronizationManager에서 관리하는 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);
...