서비스 추상화 1

자바, 스프링 개발자들의 종착역이자, 주기적으로 다시 돌아오는 그곳, <토비의 스프링 3.1>을 드디어 읽는다.

한창 개발 걸음마를 막 떼고 스프링으로 아장아장 기어다니며 CRUD를 할 때즈음, 토비의 스프링을 찬양하고 있는 많은 개발자들을 보았고, 역시 지식이 깊어지려면, 책을 통해서 지식을 정리하고 깊이를 다지는 시간이 필요하겠구나, 생각했다. 하지만, 개발을 이제 막 배운 그때의 나에게 토비 책은 너무나도 무시무시했고, 토비책을 읽다가 개발을 포기한 사람들의 증언도 여럿 읽고 나니, 이 책은 개발자로서 레베루업이 필요할 때즈음, 다시 꺼내봐야겠다고 생각했다.

그리고 지금이다. 퇴사 후, 이직을 준비하는 동안, 지식의 깊이를 좀 더 다져놓고 싶다는 생각이 들었고, 집앞 도서관에서 (아무도 빌려가지 않는) 토비님의 책을 빌려서 읽기 시작했다. 처음에는 가볍게 읽기 시작했지만, 읽을 수록 인사이트가 쌓여, 기록하면서 제대로 읽고 싶어졌다. 그래서 나의 세컨 브레인인 이곳 블로그에 짧은 글들로 그 내용을 적어보며, 토비님으로부터 얻은 인사이트를 내것으로 만들어보고자 한다.

사용자 레벨 관리 기능을 추가해보자

지금까지 진행해온 것은 단순히 user 를 추가하고 삭제하고 조회하는 CRUD 였다. 여기에 사용자 레벨의 개념을 도입하여 사용자를 관리할 수 있는 기능을 추가해보려고 한다. 추가하고자 하는 내용은 다음과 같다.

  • 사용자 레벨은 BASIC, SILVER, GOLD

  • 최초 가입시는 BASIC, 50번 이상 로그인하면 SILVER, SILVER 이면서 추천을 30번 이상 받으면 GOLD 가 된다.

  • 사용자 레벨의 변경작업은 일정 주기를 두고 발생한다. 그 전까지는 조건을 충족하더라도 레벨이 변경되지 않는다.

Enum class

  • 자바 5 이상부터는 Enum class 를 제공해준다.

  • 레벨과 같이 DB 단에서는 숫자로 관리하여 데이터를 관리하지만 어플리케이션 코드 단에서는 단순한 숫자를 이용하면 오류의 가능성이 있는 경우, 사용하면 좋다.

  • 위의 사용자 레벨과 같은 경우, 아래와 같이 정의할 수 있다.

    • 내부에는 데이터베이스에서 저장될 int 값을 가지고 있지만,

    • 겉으로 보기에는 Level 타입의 오브젝트이기 때문에 안전하게 사용이 가능하다. 컴파일러 단에서 타입과 맞지 않는 값을 걸러주기 때문이다.

public enum Level {
	BASIC(1),
	SILVER(2),
	GOLD(3);
	
    private final int value;

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

    public int intValue() {
        return this.value;
    }

    public static Level valueOf(int value) {
        switch(value) {
        case 1:
            returnBASIC;
        case 2:
            returnSILVER;
        case 3:
            returnGOLD;
        default:
            throw new AssertionError("Unknown value: " + value);
        }
    }
}

User 에 Level 등 필드 추가

  • User 클래스에 사용자의 레벨, 로그인 횟수, 추천 횟수 등의 필드를 추가한다.

public class User {
    String id;
    String name;
    String password;

    Level level;  //추가된 필드들 
    int login;
    int recommend;

    ...

    public Level getLevel() {
        return this.level;
    }

    public void setLevel(Level level) {
        this.level = level;
    }

    public int getLogin() {
        return this.login;
    }

    public void setLogin(int login) {
        this.login = login;
    }

    public int getRecommend() {
        return this.recommend;
    }

    public void setRecommend(int recommend) {
        this.recommend = recommend;
    }
}

UserDaoTest 수정

  • 필드가 추가되었으니 테스트도 같이 수정해준다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"/test-applicationContext.xml"})
public class UserDaoTest {
    @Autowired
    UserDao dao;
    @Autowired
    DataSource dataSource;
    private User user1;
    private User user2;
    private User user3;

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

    @Test
    public void andAndGet() {
        this.dao.deleteAll();
        Assert.assertThat(this.dao.getCount(), CoreMatchers.is(0));
        this.dao.add(this.user1);
        this.dao.add(this.user2);
        Assert.assertThat(this.dao.getCount(), CoreMatchers.is(2));

        User userget1 = this.dao.get(this.user1.getId());
        this.checkSameUser(userget1, this.user1);

        User userget2 = this.dao.get(this.user2.getId());
        this.checkSameUser(userget2, this.user2);
    }

	...

    private void checkSameUser(User user1, User user2) {
      Assert.assertThat(user1.getId(), CoreMatchers.is(user2.getId()));
      Assert.assertThat(user1.getName(), CoreMatchers.is(user2.getName()));
      Assert.assertThat(user1.getPassword(), CoreMatchers.is(user2.getPassword()));
      Assert.assertThat(user1.getLevel(), CoreMatchers.is(user2.getLevel()));
      Assert.assertThat(user1.getLogin(), CoreMatchers.is(user2.getLogin()));
      Assert.assertThat(user1.getRecommend(), CoreMatchers.is(user2.getRecommend()));
  }
}

UserDaoJdbc 수정

public class UserDaoJdbc implements UserDao {
    private JdbcTemplate jdbcTemplate;
    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("login"));
            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(?,?,?,?,?,?)", new Object[]{user.getId(), user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend()});
    }

}

사용자 수정 기능 추가

  • 사용자 관리 기능의 명세를 보았을 때, 당연히 레벨, 로그인 횟수, 추천횟수 등이 수정될 여지가 다분하다. 따라서 사용자 정보를 수정할 수 있는 기능이 추가되어야 할 것이다.

  • 필드마다 수정을 할 수도 있겠지만, 우선 여기서는 절대 바뀌지 않는 사용자의 id 만을 제외하고 나머지 정보는 받아서 수정을 처리하는 것으로 하자.

  • UserDao 에 update() 기능을 추가해주면, UserDaoJdbc 에서도 update() 를 추가해준다.

  • 여기서 중요한 점은 update() 매소드의 실행 결과, 업데이트 대상만 바뀌고 나머지 데이터들에게는 영향이 없는지도 같이 확인해봐야한다는 점이다. 그를 위해서 user1과 user2 를 넣고 user1의 정보만 변경하는 방식으로 수정 테스트를 진행했다.


//UserDaoTest
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(
    locations = {"/test-applicationContext.xml"}
)
public class UserDaoTest {
    @Autowired
    UserDao dao;
    @Autowired
    DataSource dataSource;
    private User user1;
    private User user2;
    private User user3;

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

...

    @Test
    public void update() {
        this.dao.deleteAll();
        this.dao.add(this.user1);
        this.dao.add(this.user2);
        this.user1.setName("오민규");
        this.user1.setPassword("springno6");
        this.user1.setLevel(Level.GOLD);
        this.user1.setLogin(1000);
        this.user1.setRecommend(999);
        this.dao.update(this.user1);

        User user1update = this.dao.get(this.user1.getId());
        this.checkSameUser(this.user1, user1update);

        User user2same = this.dao.get(this.user2.getId());
        this.checkSameUser(this.user2, user2same);
    }
}

//UserDao
public interface UserDao {
    ...
    void update(User var1);
}

//UserDaoJdbc
public class UserDaoJdbc implements UserDao {
    ...
    public void update(User user) {
        this.jdbcTemplate.update("update users set name = ?, password = ?, level = ?, login = ?, recommend = ? where id = ? ", new Object[]{user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend(), user.getId()});
    }
}

UserService.upgradeLevels()

  • 이제 사용자를 전체적으로 조회하여 그들의 방문수와 추천수를 기준점(30, 50)과 비교하며 레벨을 올려줄 대상은 올려주는 비즈니스 로직을 짜야한다. 그런데, 이 로직은 어디에 들어가면 좋을까?

  • 비즈니스 로직과 데이터 엑세스 로직은 분리되는 것이 좋다. 또한 비즈니스 로직은 데이터 엑세스 로직에 의해 절대로 영향받지 않아야 한다.

  • 이를 위해서는 UserService 클래스를 생성해주고, 그의 테스트 클래스도 생성해준다. 또한 UserService 를 스프링에 빈으로 등록하여 DI 받을 수 있도록 한다.

    • UserServiceTest → UserService → UserDao ← UserDaoJdbc, UserDaoJpa

  • upgradeLevels() 메소드를 구현한다. 사용자 정보를 전체 조회하여 현재 레벨, 업데이트 레벨 조건, 레벨 변경 여부 등을 체크하는 로직을 통해 기능을 구현한다.

  • add() 메소드도 추가해준다. 신규 유저가 추가되었을 때, BASIC 레벨로 자동으로 추가될 수 있도록 한다.

    • 이 작업은 데이터 엑세스 로직이 아니므로, 비즈니스 관련 로직인 UserService 단에서 진행한다.

    • 사용자가 새롭게 추가되었는데 미리 정해진 레벨이 없다면, BASIC 레벨로 setting 될 수 있도록 짠다.

      • if (user.getLevel() == null) {user.setLevel(Level.BASIC);}

코드 개선

코드를 개선할 때에는 다음과 같은 점들을 체크해보면 좋다.

  • 코드에 중복된 부분은 없는지?

  • 코드가 무엇을 하는 것인지 이해하기 불편한가

  • 코드가 자신이 있어야 할 자리에 있는가

  • 앞으로 변경이 일어난다면 어떤 것이 있을 수 있는가, 그 변화에 쉽게 대응할 수 있는가

upgradeLevels() 메소드 코드에는 어떤 문제점이 있을까?

  • 초기 버전의 메소드 코드에서는 for-loop 속에서 현재 레벨을 검사하는 로직과 업데이트 조건 검사, 다음 레벨로 업데이트하는 로직, 그리고 변경 여부를 검사하는 플래그까지 관련있어 보이지만 사실은 성격이 조금씩 다른 것들이 섞여있어서 읽기도 불편하고 한눈에 파악하기가 어려웠다.

  • 게다가 level 에 따라서 IF 조건문을 쓰고 있어서, 레벨이 하나 추가될 떄마다 조건문이 하나씩 추가되는 구조였다.

개선하기

  • 우선, canUpgradeLevel, upgradeLevel 등으로 업그레이드 조건을 확인하는 로직과 실제로 레벨을 업그레이드하는 로직을 분리하여 서로가 각자의 관심도에 더욱 몰입할 수 있도록 변경하였고

  • 다음 레벨로 업그레이드 하는 작업 역시, 각 레벨별로 다음 레벨 값을 가지고 있도록 해서 레벨 업데이트할 때, 다음 레벨에 대해서 신경쓰지 않도록 조치했다.

  • 사용자 레벨을 업그레이드 하는 작업은 UserService 단 보다는 User 의 관심사에 더 가까우므로, User 내부로 레벨 업데이트 로직을 이동시켜주었다.

  • 반복되는 레벨 조건인 50, 30을 상수로 만들어두어 클래스를 넘나들며 쓰일 수 있도록 한다.

<bean id="userService" class="springbook.user.service.UserService">
   <property name="userDao" ref="userDao" />
</bean>
public class UserService {
    public static final int MIN_LOGCOUNT_FOR_SILVER= 50;
    public static final int MIN_RECCOMEND_FOR_GOLD= 30;

    private UserDao userDao;

    public UserService() {
    }

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

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

        while(var3.hasNext()) {
            User user = (User)var3.next();
            if (this.canUpgradeLevel(user)) {
                this.upgradeLevel(user);
            }
        }
    }

    private boolean canUpgradeLevel(User user) {
        Level currentLevel = user.getLevel();
        switch($SWITCH_TABLE$springbook$user$domain$Level()[currentLevel.ordinal()]) {
        case 1:
            return false;
        case 2:
            if (user.getRecommend() >= 30) {
                return true;
            }

            return false;
        case 3:
            if (user.getLogin() >= 50) {
                return true;
            }

            return false;
        default:
            throw new IllegalArgumentException("Unknown Level: " + currentLevel);
        }
    }

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

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

        this.userDao.add(user);
    }
}

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

    public UserServiceTest() {
    }

    @Before
    public void setUp() {
        this.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, 2147483647));
    }

    @Test
    public void upgradeLevels() {
        this.userDao.deleteAll();
        Iterator var2 = this.users.iterator();

        while(var2.hasNext()) {
            User user = (User)var2.next();
            this.userDao.add(user);
        }

        this.userService.upgradeLevels();

        this.checkLevelUpgraded((User)this.users.get(0), false);
        this.checkLevelUpgraded((User)this.users.get(1), true);
        this.checkLevelUpgraded((User)this.users.get(2), false);
        this.checkLevelUpgraded((User)this.users.get(3), true);
        this.checkLevelUpgraded((User)this.users.get(4), false);
    }

    private void checkLevelUpgraded(User user, boolean upgraded) {
        User userUpdate = this.userDao.get(user.getId());
        if (upgraded) {
            Assert.assertThat(userUpdate.getLevel(), CoreMatchers.is(user.getLevel().nextLevel()));
        } else {
            Assert.assertThat(userUpdate.getLevel(), CoreMatchers.is(user.getLevel()));
        }

    }

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

        User userWithLevel = (User)this.users.get(4);
        User userWithoutLevel = (User)this.users.get(0);
        userWithoutLevel.setLevel((Level)null);  //default level 이 없는 신규 유저 테스트 
        this.userService.add(userWithLevel);
        this.userService.add(userWithoutLevel);

        User userWithLevelRead = this.userDao.get(userWithLevel.getId());
        User userWithoutLevelRead = this.userDao.get(userWithoutLevel.getId());

        Assert.assertThat(userWithLevelRead.getLevel(), CoreMatchers.is(userWithLevel.getLevel()));
        Assert.assertThat(userWithoutLevelRead.getLevel(), CoreMatchers.is(Level.BASIC));
    }
}

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

    ...

    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
public class User {
    ...
    public void upgradeLevel() {
        Level nextLevel = this.level.nextLevel();
        if (nextLevel == null) {
            throw new IllegalStateException(this.level + "은  업그레이드가 불가능합니다");
        } else {
            this.level = nextLevel;
        }
    }
}

리펙토링 결과 돌아보기

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

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

  • UserService 에서 User 의 레벨 데이터를 가져와 직접 변경하는 것보다는 UserService → User 에게 레벨을 업그레이드 시켜줘라고 요청을 하고, User → Level 에게 level 다음 레벨은 무엇인지 알려줘 라고 요청하는 방식으로 변경하는 것이 바람직하다.

UserTest, UserServiceTest 개선하기

  • User 내부에 upgradeLevel 에 관한 테스트가 추가되었으므로, 이에 대한 테스트를 추가해준다.

  • User 의 경우는 스프링에서 관리하는 빈이 아니므로, 테스트 과정에서 스프링 컨텍스트가 필요하지 않다. 그냥 객체를 만들어서 테스트하면 된다.

  • UserServiceTest 의 upgradeLevels() 테스트 같은 경우는 업데이트할 레벨에 관심을 거두고 User 에서 제대로 레벨이 달라졌는지만 비교하면 된다.

public class UserTest {
    User user;

    public UserTest() {
    }

    @Before
    public void setUp() {
        this.user = new User();
    }

    @Test
    public void upgradeLevel() {
        Level[] levels = Level.values();
        Level[] var5 = levels;
        int var4 = levels.length;

        for(int var3 = 0; var3 < var4; ++var3) {
            Level level = var5[var3];
            if (level.nextLevel() != null) {
                this.user.setLevel(level);
                this.user.upgradeLevel();
                Assert.assertThat(this.user.getLevel(), CoreMatchers.is(level.nextLevel()));
            }
        }

    }

    @Test(
        expected = IllegalStateException.class
    )
    public void cannotUpgradeLevel() {
        Level[] levels = Level.values();
        Level[] var5 = levels;
        int var4 = levels.length;

        for(int var3 = 0; var3 < var4; ++var3) {
            Level level = var5[var3];
            if (level.nextLevel() == null) {
                this.user.setLevel(level);
                this.user.upgradeLevel();
            }
        }

    }
}

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

    public UserServiceTest() {
    }

    @Before
    public void setUp() {
        this.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, 2147483647));
    }

    @Test
    public void upgradeLevels() {
        this.userDao.deleteAll();
        Iterator var2 = this.users.iterator();

        while(var2.hasNext()) {
            User user = (User)var2.next();
            this.userDao.add(user);
        }

        this.userService.upgradeLevels();
        this.checkLevelUpgraded((User)this.users.get(0), false);
        this.checkLevelUpgraded((User)this.users.get(1), true);
        this.checkLevelUpgraded((User)this.users.get(2), false);
        this.checkLevelUpgraded((User)this.users.get(3), true);
        this.checkLevelUpgraded((User)this.users.get(4), false);
    }

    private void checkLevelUpgraded(User user, boolean upgraded) {
        User userUpdate = this.userDao.get(user.getId());
        if (upgraded) {
            Assert.assertThat(userUpdate.getLevel(), CoreMatchers.is(user.getLevel().nextLevel()));
        } else {
            Assert.assertThat(userUpdate.getLevel(), CoreMatchers.is(user.getLevel()));
        }

    }

    @Test
    public void add() {
        this.userDao.deleteAll();
        User userWithLevel = (User)this.users.get(4);
        User userWithoutLevel = (User)this.users.get(0);
        userWithoutLevel.setLevel((Level)null);
        this.userService.add(userWithLevel);
        this.userService.add(userWithoutLevel);
        User userWithLevelRead = this.userDao.get(userWithLevel.getId());
        User userWithoutLevelRead = this.userDao.get(userWithoutLevel.getId());
        Assert.assertThat(userWithLevelRead.getLevel(), CoreMatchers.is(userWithLevel.getLevel()));
        Assert.assertThat(userWithoutLevelRead.getLevel(), CoreMatchers.is(Level.BASIC));
    }
}

유연한 구조로 만들기

  • 레벨 정책과 같은 경우는 사용자 레벨 업그레이드 정책을 UserService 에서 분리하여 interface 로 관리하도록 함으로써 좀 더 유연한 구조로 만들 수 있다.

  • 만약에 이벤트 기간에는 좀 더 후한 조건으로 레벨 업그레이드를 해준다면 아래와 같은 구조로 변경해볼 수 있을 것이다.

    • interface - UserLevelUpgradePolicy

      • UserLevelUpgradeNormalPolicy

      • UserLevelUpgradeChristmasPolicy

      • UserLevelUpgradeNewYearPolicy

    • UserService → UserLevelUpgradePolicy ← UserLevelUpgradeNormalPolicy, UserLevelUpgradeChristmasPolicy, UserLevelUpgradeNewYearPolicy

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

다음편에 계속!

Last updated