서비스 추상화
환경과 상황에 따라서 기술이 바뀌고, 그에 따라 다른 API를 사용하고 다른 스타일의 접근 방법을 따라야 하는 것은 피곤한 일이다. 이번 챕터에서는 지금까지 만든 DAO에 트랜잭션을 적용해보면서 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있는지 살펴보자.
사용자 레벨 관리 기능 추가
지금까지 만들었던 UserDao는 기본적인 CRUD기능만 하고 있는데 여기에 간단한 비즈니스 로직을 추가해보자.
- 사용자 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나다.
- 사용자가 처음 가입하면 BASIC 레벨이 되며, 이후 활동에 따라서 한 단계씩 업그레이드될 수 있다.
- 가입 후 50회 이상 로그인을 하면 BASIC에서 SILVER 레벨이 된다.
- SILVER 레벨이면서 30번 이상 추천을 받으면 GOLD레벨이 된다.
- 사용자 레벨의 변경 작업은 일정한 주기를 가지고 일괄적으로 진행된다. 변경 작업 전에는 조건을 충족하더라도 레벨의 변경이 일어나지 않는다.
필드 추가
사용자의 레벨을 저장할 필드를 추가해야하는데, DB의 User 테이블에는 어떤 타입으로 넣을 것인지, 또 이에 매핑되는 자바의 User클래스에는 어떤 타입으로 넣을지 생각해보자.
- DB에 varchar 타입으로 선언하고 “BASIC”, “SILVER”, “GOLD”라고 문자를 넣는 방법
→ 이름 바꾸면 유지 보수가 힘들어진다. - 레벨을 코드화해서 숫자로 넣는 방법
→ 숫자는 범위가 작기 때문에 DB 용량을 적게 차지한다.
class User {
private static final int BASIC = 1;
private static final int SILVER = 2;
private static final int GOLD = 3;
public void setLevel(int level) {
this.level = level;
}
}
이처럼 BASIC, SILVER, GOLD처럼 의미 있는 상수로 정의하니 깔끔한 코드 작성이 가능해졌다. 하지만, 문제는 level의 타입이 int이기 때문에 다음처럼 다른 종류의 정보를 넣는 실수를 해도 컴파일러가 체크해주지 못한다는 점이다.
user1.setLevel(other.getSum());
user1.setLevel(1000);
그래서 숫자 타입을 직접 사용하는 것보다는 enum을 이용하는 것이 편리하고 안전하다.
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);
}
}
}
이렇게 만들어진 Level 클래스는 내부에는 DB에 저장할 int 타입의 값을 갖고 있지만, 겉으로는 Level 타입의 오브젝트이기 때문에 안전하게 사용할 수 있다.
사용자 수정 기능 추가
수정할 정보가 담긴 User 오브젝트를 전달하면 id를 참고해서 사용자를 찾아 필드 정보를 UPDATE 문을 이용해 모두 변경해주는 메소드를 하나 만들어야 한다. 이때 주로 실수가 일어나는 곳은 SQL문장이다. UPDATE 문장에서 WHERE절을 빼먹는 경우 테스트로는 검증 하지 못하는 에러가 되어 버린다.
이 문제를 해결할 방법은 두 가지이다.
- JdbcTemplate의 update()가 돌려주는 리턴 값을 확인
→ JdbcTemplate의 UPDATE나 DELETE 같이 테이블의 내용에 영향을 주는 SQL을 실행하면 영향받은 로우의 개수를 돌려준다. - 테스트를 보강해서 원하는 사용자 외의 정보는 변경되지 않았음을 직접 확인
2번째 방법을 사용해서 이를 해결해보자.
@Test
public void update() throws Exception {
userDao.deleteAll();
userDao.add(user1); // 수정할 사용자
userDao.add(user2); // 수정하지 않을 사용자
user1.setName("오민규");
user1.setPassword("springno6");
user1.setLevel(Level.GOLD);
user1.setLogin(1000);
user1.setRecommend(999);
userDao.update(user1);
checkSameUser(user1, userDao.get(user1.getId()));
checkSameUser(user2, userDao.get(user2.getId()));
}
테스트를 작성했으니 이제 사용자 관리 비즈니스 로직을 구현해보자. 사용자 관리 로직은 어디다 두는 것이 좋을까? UserDaoJdbc는 적당하지 않다. DAO는 데이터를 어떻게 가져오고 조작할지를 다루는 곳이지 비즈니스 로직을 두는 곳이 아니다. 사용자 관리 비즈니스 로직을 담을 클래스를 하나 추가하자. (UserService)
UserService는 UserDao 인터페이스 타입으로 userDao 빈을 DI 받아 사용하게 만든다. (UserService는 UserDao의 구현 클래스가 바뀌어도 영향받지 않도록 해야 하기 때문에 DAO의 인터페이스를 사용하고 DI를 한다.)
@Service
public class UserService {
private UserDao userDao;
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
public void upgradeLevels() {
List<User> users = userDao.getAll();
if (level == Level.BASIC && login >= 50) {
user.setLevel(Level.SILVER);
changed = true;
} else if (level == Level.SILVER && recommend >= 30) {
user.setLevel(Level.GOLD);
changed = true;
} else if (level == Level.GOLD) {
changed = false;
} else {
changed = false;
}
if (changed) {
userDao.update(user);
}
}
}
사용자 관리 비즈니스 로직에서 사용자는 기본적으로 BASIC 레벨이어야 한다는 부분이 구현되지 않았다. 이 로직은 어디에 담는 것이 좋을까? UserDaoJdbc의 add() 메소드는 적합하지 않아 보인다. UserDaoJdbc는 주어진 User오브젝트를 DB에 정보를 넣고 읽는 방법에만 관심을 가져야지, 비즈니스적인 의미를 지닌 정보를 설정하는 책임을 지는 것은 바람직하지 않다.
- User클래스에서 level 필드를 Level.BASIC으로 초기화 하는 방법
→ 단지 이 로직을 위해서 클래스에서 직접 초기화하는 것은 좀 문제가 있어 보인다. - 사용자 관리에 대한 비즈니스 로직을 담고 있는 UserService에 이 로직을 넣는 방법
코드 개선
작성된 코드를 확인할 때 던져 봐야할 질문들
- 코드에 중복된 부분은 없는가?
- 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?
- 코드가 자신이 있어야 할 자리에 있는가?
- 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?
upgradeLevels()의 문제점
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
Boolean changed = null;
Level level = user.getLevel();
int login = user.getLogin();
int recommend = user.getRecommend();
if (level == Level.BASIC && login >= 50) {
user.setLevel(Level.SILVER);
changed = true;
} else if (level == Level.SILVER && recommend >= 30) {
user.setLevel(Level.GOLD);
changed = true;
} else if (level == Level.GOLD) {
changed = false;
} else {
changed = false;
}
if (changed) {
userDao.update(user);
}
}
}
- for 루프 속에 들어 있는 if/else if/else 블록들이 읽기 불편하게 만든다.
- 레벨의 변화 단계와 업그레이드 조건이 충족됐을 때 해야 할 작업이지만 로직이 섞여 있어 이해하기 어렵다.
- 플래그를 두고 이를 변경하고 마지막에 확인해서 업데이트를 진행하는 방법도 깔끔하지 않다.
코드가 깔끔해 보이지 않는 이유는 이렇게 성격이 다른 여러 가지 로직이 한 곳에 섞여 있기 때문이다.
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 필드를 변경해준다는 로직이 함께 있는데다, 너무 노골적으로 드러나 있다. 또한 예외상황에 대한 처리가 없다. 먼저 레벨의 순서와 다음 단계 레벨이 무엇인지를 결정하는 일을 Level에게 맡기자. 레벨의 순서를 굳이 UserService에 담아둘 필요가 없다.
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;
}
}
이번엔 사용자 정보가 바뀌는 부분을 UserService 메소드에서 User로 옮겨보자. User의 내부 정보가 변경되는 것은 UserService보다는 User가 스스로 다루는 게 적절하다.
public class User {
...
public void upgradeLevel() {
Level nextLevel = this.level.nextLevel();
if(nextLevel == null) {
throw new IllegalStateException(this.level + "은 업그레이드가 불가능합니다.");
} else {
this.level = nextLevel;
}
}
}
User 오브젝트를 UserService만 사용하는 건 아니므로 UserService가 검증 로직을 가지고 있더라도 User 자체적으로 검증 로직을 갖고 있는 편이 안전하다. UserService는 이제 User 오브젝트에게 알아서 업그레이드에 필요한 작업을 수행하라고 요청만 해주면 되기 때문에 다음처럼 간결해진다.
private void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
객체지향적인 코드는 다음과 같이 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청한다. 오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이기도 하다.
upgradeLevels() 테스트 개선
- 기존의 코드를 보면 테스트 로직이 분명하게 드러나지 않는 단점이 있다.
- checkLevel()을 호출하면서 파라미터로 Level enum을 전달하는데, 테스트 코드만 봐서는 그것이 업그레이드된 경우를 테스트하려는 것인지 쉽게 파악이 안된다.
@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()).isEqualTo(expectedLevel);
}
이 코드를 개선해보자.
@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 checkLevel(User user, boolean upgraded) {
User userUpdate = userDao.get(user.getId());
if(upgraded) {
assertThat(userUpdate.getLevel()).isEqualTo(user.getLevel().nextLevel());
} else {
assertThat(userUpdate.getLevel()).isEqualTo(user.getLevel());
}
}
개선한 upgradeLevels() 테스트는 각 사용자에 대한 업그레이드를 확인하려는 것인지 아닌지가 좀 더 이해하기 쉽게 true flase로 나타아 있어서 보기 좋다.
다음은 코드에 나타난 중복을 제거해보자. 업그레이드 조건인 로그인 횟수와 추천 횟수가 애플리케이션 코드와 테스트 코드에서 중복돼서 나타난다.
case BASIC : return (user.getLogin() >= 50) // UserService
new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0) // UserServiceTest
기준이 되는 최소 로그인 횟수가 변경될 때도 한 번만 수정할 수 있도록 만들자. 가장 좋은 방법은 정수형 상수로 변경하는 것이다.
public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMENT_FOR_GOLD = 30;
이렇게 함으로써 변경점이 최소화되었고, 가독성이 좋은 코드가 되었다.
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_RECOMMEND_FOR_GOLD);
case GOLD: return false;
default:
throw new IllegalArgumentException("Unknown Level: " + currentLevel);
}
}
더 욕심을 내면 레벨을 업그레이드하는 정책을 유연하게 변경할 수 있도록 개선하는 것도 생각해볼 수 있다. 연말 이벤트 등에서는 레벨 업그레이드 정책을 다르게 적용할 필요가 있을 수 있는데, 이럴 때 마다 UserService의 코드를 직접 수정했다가 이벤트 기간이 끝나면 다시 이전 코드로 수정한다는 것은 상당히 번거롭고 위험한 방법이다.
이런 경우 사용자 업그레이드 정책을 UserService에서 분리하는 방법을 고려해볼 수 있다. 분리된 업그레이드 정책을 담은 오브젝트는 DI를 통해 UserService에 주입한다.
public interface UserLevelUpgradePolicy {
boolean canUpgradeLevel(User user);
void upgradeLevel(User user);
}
public class UserLevelUpgradeNormal implements UserLevelUpgradePolicy {
private UserDao userDao;
public UserLevelUpgradeNormal(UserDao userDao) {
this.userDao = userDao;
}
@Override
public 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_RECOMMEND_FOR_GOLD);
case GOLD: return false;
default:
throw new IllegalArgumentException("Unknown Level: " + currentLevel);
}
}
@Override
public void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
}
@Service
public class UserService {
public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_FOR_GOLD = 30;
private UserDao userDao;
private UserLevelUpgradePolicy userLevelUpgradePolicy;
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
public void setUserLevelUpgradePolicy(UserLevelUpgradePolicy userLevelUpgradePolicy) {
this.userLevelUpgradePolicy = userLevelUpgradePolicy;
}
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
if(userLevelUpgradePolicy.canUpgradeLevel(user)) {
userLevelUpgradePolicy.upgradeLevel(user);
}
}
}
}