본문 바로가기

Back-End/토비의 스프링3

5장. 스프링 - 서비스 추상화(사용자 레벨 관리 기능 추가)

서비스 추상화

예제를 살펴보면서 스프링이 어떻게 성격이 비슷한 여러 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있도록 해주는지 살펴보자. ( 서비스 추상화 )

예제 : 사용자의 활동내역을 참고해서 사용자 레벨 관리 기능 추가

1. Level Enum 사용하기

Level enum 만들기

장점 1 : int 타입으로 레벨 상수를 사용하는 것보다 의미 있는 상수로 사용할 수 있음 

         ( int형 setLevel(10000)과 같은 버그 코드는 컴파일 에러가 나지 않음 )


장점 2 : varchar 타입보다 DB 용량을 많이 차지하지 않는다.


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

UserDaoJdbc 수정 

@Override
public void add(final 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());
}

2. UserService.upgradeLevels() 만들기

public class UserService {
private UserDao userDao;

public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}

public void upgradeLevels() {
List<User> users = userDao.getAll();

for (User user : users) {
Boolean changed = false; // 레벨 변화 체크 플래그

// BAISC 레벨 업그레이드 작업
if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
user.setLevel(Level.SILVER);
changed = true;
}

// SILVER 레벨 업그레이드 작업
else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
user.setLevel(Level.GOLD);
changed = true;
}

else if (user.getLevel() == Level.GOLD)
changed = false;
else
changed = false;

if (changed)
userDao.update(user);
}
}
}

비즈니스 로직이 추가되었으니 당연히 테스트 코드가 추가되어야 한다.


레벨업 조건에 맞는 모든 테스트 코드를 만들자.


@Before
public void setUp() {
users = Arrays.asList(new User("gyumee", "박성철", "springno1", Level.BASIC, 49, 0),
new User("gyumee2", "박성4철", "springno61", Level.BASIC, 50, 0),
new User("gyumee3", "박성5철", "springno71", Level.SILVER, 60, 29),
new User("leegw700", "이길원", "springno2", Level.SILVER, 60, 30),
new User("bumjin", "박범진", "springno3", Level.GOLD, 100, 40));
}
@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));
}

3. UserService.add()

처음 가입하는 사용자는 기본적으로 BASIC 레벨을 가져야 한다. 이 로직을 어디에 담는게 좋을까?

  1. UserDaoJdbc - add() : UserDaoJdbc는 DB에 정보를 읽고 넣는 방법에만 관심을 가져야 한다. 비즈니스적인 의미를 지닌 정보를 설정 책임을 가지면 안됨
  2. User - default level 값을 BASIC : 처음 가입할때를 제외하면 무의미한 값인데 클래스에 직접 초기화하는 것은 문제가 있음
  3. UserService - add() : 사용자 관리에 관한 비즈니스 로직을 담고 있는 UserService가 가장 적합
public void add(User user) {
if (user.getLevel() == null)
user.setLevel(Level.BASIC);
userDao.add(user);
}

테스트 코드를 작성하자

@Test
public void add() {
userDao.deleteAll();

User userWithLevel = users.get(4); // GOLD 레벨 ( 레벨을 초기화하지 않아야 함 )
User userWithoutLevel = users.get(0); // BASIC 레벨
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(userWithoutLevel.getLevel()));

}

테스트코드에 DAO와 DB까지 모두 동원되는 점은 개선되어야 한다. ( 뒤쪽에서 살펴보자 )


4. 코드 개선하기

  • 코드에 중복은 없는가?
  • 코드를 이해하는데 불편하지 않은가?
  • 코드가 자신이 있어야 할 자리에 있는가?
  • 앞으로 변경이 일어나면 어떤 것이 있을 수 있고, 변경에 유연하게 대응할 수 있느냐?
/**
* 해당 코드의 문제점
*
* 1) if,else if,else 읽기가 불편하다.
*
* 1. 현재 레벨 파악 2. 업그레이드 조건을 담는 로직 3. 업그레이드를 위한 작업 4. update를 위한 플래그
*
* 위와 같이 관련이 있어 보이지만 성격이 다른것들이 섞여 있음
*
*
* 2) Level이 추가된다면?
*
* enum도 수정하고 if문은 추가된 만큼 늘어난다. -> 메소드는 점점 복잡해짐
*/
// SILVER 레벨 업그레이드 작업
else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
user.setLevel(Level.GOLD);
changed = true;
}


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


다음으로 실제로 레벨을 업그레이드 시켜주는 코드를 작성하자.



/**
* upgradeLevel을 따로 두면 좋은 점?
* 만약 나중에 레벨이 오른 걸 사용자에게 알려줘야 하는 로직을 추가할 경우,
* 어디를 수정해야 할 지 쉽게 찾을 수 있음.
*
*/
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);
}

하지만, 이 코드또한 

 1. 레벨이 늘어나면 if문이 늘어날테고

 2. 레벨변경시 다른 값도 변경해야 한다면 if문이 길어질테고

 3. GOLD 레벨을 실수로 업그레이드 하는 코드가 들어갈 수도 있다.


이것도 더 분리해서 다음 레벨은 레벨이 스스로 결정하게 만들어보자.


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

private final int value;
private final Level nextLevel;

Level(int value, Level nextLevel) {
this.value = value;
this.nextLevel = nextLevel;
}

public int intValue() {
return value;
}

public Level nextLevel() {
return this.nextLevel;
}


다음으로 UserService에서 사용자 정보를 수정하였는데, User의 내부 정보가 바뀌는 것이기 때문에 스스로 다루는게 적절하다. User클래스를 수정하자.

public void upgradeLevel() {
Level nextLevel = this.level.nextLevel();

if (nextLevel == null)
throw new IllegalStateException(this.level + "은 업그레이드가 불가능합니다.");

else
this.level = nextLevel;
}


UserService는 다음과 같이 간결해진다. 그리고 예를 들어, 업그레이드 된 날짜를 기록하는 코드를 추가해야 하는 경우, 어느 부분에 추가해야 되는지 파악하기가 쉽다.

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


각 오브젝트와 메소드가 각자 자기 몫의 책임을 맡아 일을 하는 구조로 만들어졌다.


 객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청을 해야한다. 오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이다.


UserServiceTest 개선 - 코드에 나타난 중복


로그인 회수나 추천 횟수가 서비스 코드와 테스트 코드에 중복으로 관리되고 있다.


new User("gyumee", "박성철", "springno1", Level.BASIC, MIN_LOGCOUNT_FOR_SILVER - 1, 0),

UserService

public static final int MIN_LOGCOUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_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를 이용
*/
public interface UserLevelUpgradePolicy {
boolean canUpgradeLevel(User user);

void upgradeLevel(User user);
}