토비의 스프링 정리
토비의 스프링 - 5.1 서비스 추상화
ksb-dev
2022. 10. 3. 20:58
5.1.1 사용자 레벨 관리 기능 추가
- UserDao는 CRUD를 제외하고 어떤 비즈니스 로직을 갖지 않음
- 사용자 관리 모듈 기능 추가
- 정기적으로 유저의 활용 내용을 참조해서 레벨을 조정
- 비즈니스 로직
- 사용자의 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나
- 처음 가입자면 BASIC. 활동에 따라 한 단계씩 업그레이드
- 가입후 50회 이상 로그인 하면 BASIC → SILVER
- SILVER 이면서 30번 이상 추천을 받으면 GOLD
- 레벨 변경 작업은 일정 주기를 가지고 일괄적으로 진행
5.1.2 정수형 상수 값의 사용자 레벨
- 각 레벨을 코드화 해서 숫자로 넣음
public class User {
...
int level;
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;
}
}
- 다른 종류의 정보를 넣는 실수를 해도 컴파일러가 체크하지 못하는 단점 존재
user1.setLevel(1000);
5.1.3 Level 이늄(Enum) 추가
- 레벨을 나타낼 숫자를 직접 사용하는 것보다 안전
- Level 이늄 내부에는 DB에 저장할 int 타입의 값을 갖고 있지만, 겉으로는 Level 타입의 오브젝트이기 때문에 안전하게 사용 가능
- valueOf()로 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);
}
}
}
5.1.4 User 필드추가
- 레벨 관리 로직에서 언급된 로그인 횟수와 추천수 추가
필드명 타입 설정
level | tinyint | Not NullKey |
login | int | Not Null |
recommend | int | Not Null |
ALTER TABLE users ADD level tinyint not null;
ALTER TABLE users ADD login int not null;
ALTER TABLE users ADD recommend int not null;
public class User {
...
Level level;
int login;
int recommend;
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;
}
... //getter, setter
}
5.1.5 테스트 수정
- addAndGet()에서 assertThat()를 일정한 로직을 위해 checkSameUser()로 대체
public class UserDaoTest {
...
private final User user1 = new User("gyumee", "k1n", "k1p",
Level.BASIC, 1, 0);
private final User user2 = new User("leegw700", "k2n", "k2p",
Level.SILVER, 55, 10);
private final User user3 = new User("bumjin", "k3n", "k3p",
Level.GOLD, 100, 40);
private void checkSameUser(User pUser1, User pUser2) {
assertThat(pUser1.getId(), is(pUser2.getId()));
assertThat(pUser1.getName(), is(pUser2.getName()));
assertThat(pUser1.getPassword(), is(pUser2.getPassword()));
assertThat(pUser1.getLevel(), is(pUser2.getLevel()));
assertThat(pUser1.getLogin(), is(pUser2.getLogin()));
assertThat(pUser1.getRecommend(), is(pUser2.getRecommend()));
}
@Test
public void addAndGet() {
...
User userGet1 = dao.get(user1.getId());
checkSameUser(userGet1, user1);
User userGet2 = dao.get(user2.getId());
checkSameUser(userGet2, user2);
}
...
}
5.1.6 UserDaoJdbc 수정
- Insert SQL문 및 조회 작업에 사용되는 userMapper 수정
- 이늄은 DB에 저장될 수 있는 SQL 타입이 아님
- 따라서, DB에 저장 가능한 정수형 값으로 변환해야 함
- Level 이늄의 intValue()로 insert문 안에 넣을 레벨별 int 값을 가져오고, valueOf()로 레벨 오브젝트를 가져옴
public class UserDaoJdbc implements UserDao{
private RowMapper<User> userMapper =
new RowMapper<User>() {
@Override
public User mapRow(ResultSet rs, int rowNum)
throws SQLException {
...
user.setLevel(Level.valueOf(rs.getInt("level")));
user.setLogin(rs.getInt("login"));
user.setRecommend(rs.getInt("recommend"));
return user;
}
};
public void add(final User user) {
...
int level = user.getLevel().intValue();
int login = user.getLogin();
int recommend = user.getRecommend();
String query = "insert into users(id, name, password, " +
"level, login, recommend) value (?,?,?,?,?,?)";
this.jdbcTemplate.update(query, id, name, password,
level, login, recommend);
}
}
💡 많은 추가 및 수정이 있지만, 포괄적인 테스트 덕분에 오류를 체크할 수 있었음
5.1.7 사용자 수정 기능 추가
- id를 제외한 나머지 필드는 수정할 수 있음
- update를 통해 사용자 수정
public class UserDaoTest {
...
@Test
public void update(){
dao.deleteAll();
dao.add(user1);
user1.setName("ksb");
user1.setPassword("ksb-p");
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 {
...
void update(User user1);
}
public class UserDaoJdbc implements UserDao {
...
public void update(User user1) {
String query = "update users set name=?, password=?, level=?, " +
"login=?, recommend=? where id=?";
String id = user1.getId();
String name = user1.getName();
String password = user1.getPassword();
int level = user1.getLevel().intValue();
int login = user1.getLogin();
int recommend = user1.getRecommend();
this.jdbcTemplate.update(query, name, password,
level, login, recommend, id);
}
}
5.1.8 수정 테스트 보완
- update의 where가 없어도 정상적으로 동작(단, 모든 로우의 id를 제외한 필드 값 변경)
- 보완 방법 두 가지 존재
- 영향 받은 로우 개수 확인
- 직접 확인
- update나 delete와 같이 테이블의 내용에 영향을 주는 SQL 문을 실행하면 영향 받은 로우의 개수를 반환함
- add(), deleteAll(), update() 메소드 리턴 타입을 int로 바꾸면 영향받은 로우의 개수를 알 수 있음
- 두 명의 사용자를 등록하고 원하는 사용자 이외에 정보가 변경되지 않았음을 확인
public class UserDaoTest {
...
@Test
public void update(){
dao.deleteAll();
dao.add(user1);
dao.add(user2);
user1.setName("ksb");
user1.setPassword("ksb-p");
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.9 UserService 클래스와 빈 등록
- DAO는 데이터를 조회 및 조작을 담당하는 곳임
- 사용자 관리 비지니스 로직을 DAO에 담는 것은 적당하지 않음
- 사용자 관리 비지니스 로직을 UserService 클래스에 담음
- UserService는 UserDao의 구현 클래스가 바뀌어도 영향을 받으면 안됨
- 때문에, DAO의 인터페이스 사용으로 DI를 적용
- DI 받기 위해서 UserService도 빈으로 등록되야함
public class UserService {
UserDao userDao;
public void setUserDao(UserDao userDao){
this.userDao = userDao;
}
}
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "/applicationContext.xml")
public class UserServiceTest {
@Autowired
UserService userService;
@Test
public void bean(){
assertThat(this.userService, is(notNullValue()));
}
}
<beans
...>
<bean id="userService" class="com.ksb.spring.UserService">
<property name="userDao" ref="userDao"/>
</bean>
<bean id="userDao" class="com.ksb.spring.UserDaoJdbc">
<property name="dataSource" ref="dataSource"/>
</bean>
<beans>
5.1.10 upgradeLevels() 메소드
- 비즈니스에 맞춰 user의 level을 업그레이드 하는 로직 구현
- 경계 값을 나눠 정상적으로 level 업그레이드 되는지 확인
public class UserServiceTest {
@Autowired
UserService userService;
@Autowired
UserDao userDao;
List<User> users;
@Before
public void setUp(){
users = Arrays.asList(
new User("k1", "k1", "k1", Level.BASIC, 49 ,0),
new User("k2", "k2", "k2", Level.BASIC, 50 ,0),
new User("k3", "k3", "k3", Level.SILVER, 60 ,29),
new User("k4", "k4", "k4", Level.SILVER, 60 ,30),
new User("k5", "k5", "k5", Level.GOLD, 100 ,100)
);
}
@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));
}
}
public class UserService {
...
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
Boolean changed = null;
if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
user.setLevel(Level.SILVER); //basic 업그레이드
changed = true;
} else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
user.setLevel(Level.GOLD); //silver 업그레이드
changed = true;
} else if (user.getLevel() == Level.GOLD) {
changed = false;
} else {
changed = false;
}
//level 변경이 발생 할 경우
if (changed) userDao.update(user);
}
}
}
5.1.11 아직 구현되지 않은 비즈니스 로직
- 처음 가입한 사용자는 기본적으로 BASIC 레벨이어야 하는 로직이 구현 안됨
- UserDaoJdbc는 주어진 DB정보를 넣고 빼는 방법에만 관심을 가져야지, 이런 비즈니스 로직을 넣기에 적합하지 않음
- UserService가 적합함
- User 오브젝트의 레벨이 비어있으면 BASIC으로 설정하고, 미리 설정된 레벨이 있으면 유지하는 로직으로 구현
- 로직 실행 이후 get()을 이용해 DB에 저장된 User 정보를 가져와 확인하면, UserService가 UserDao를 제대로 사용하는지 검증을 할 수 있고, 디폴트 레벨 설정 후 UserDao를 호출하는지 확인할 수 있음
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(userWithoutLevel.getLevel()));
}
}
public class UserService {
...
public void add(User user) {
if(user.getLevel() == null) user.setLevel(Level.BASIC);
userDao.add(user);
}
}
5.1.12 upgradeLevels()의 문제점 과 리팩토링 - 1
- upgradeLevels()는 레벨 파악, 업레이드 조건, 플래그 등이 존재
- 또한, if 조건이 레벨 개수만큼 반복
- canUpgradeLevel()로 업그레이드 가능한지 확인
- 업그레이드 가능하면 upgradeLevel()로 레벨 업그레이드
- upgradeLevel() 처럼 업그레이드 부분을 분리하면 나중에 안내 메일, 로그, 통보와 같은 비즈니스 로직을 추가할 때 편리함
public class UserService {
...
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);
}
}
5.1.13 upgradeLevels()의 문제점 과 리팩토링 - 2
- upgradeLevels()는 아직 많은 문제를 가지고 있음
- 다음 단계 레벨이 무엇인가 하는 로직과 사용자 오브젝트의 level 필드를 변경해 준다는 로직이 같이 있고 예외 처리가 없음
- 때문에 다음 단계 레벨이 무엇인지에 대한 로직이 UserService에 있을 필요가 없음
- 해당 로직을 Level 이늄에 넘김
- 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 Level nextLevel(){
return this.next;
}
...
}
5.1.14 User 오브젝트의 내부정보 변경
- 사용자 정보가 변경되는 부분을 UserService에서 User로 옮김
- User는 사용자 정보를 담고 있는 단순한 자바빈이긴 하지만, User도 자바 오브젝트 이며 내부 정보를 다루는 기능이 있을 수 있음
- 따라서, UserService가 일일이 User 오브젝트의 필드 정보를 수정하는 것이 아닌, User 오브젝트에게 필드를 변경하라고 요청을 하는것이 적절함
public class User {
...
public void upgradeLevel() {
Level netLevel = this.level.nextLevel();
if (netLevel == null)
throw new IllegalStateException(this.level + "은 업그레이드 불가");
else
this.level = netLevel;
}
}
public class UserService {
...
private void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
}
5.1.15 리팩토링 결과
- 코드가 간결해지고, 작업 내용 및 책임이 분리됨
- 자신의 책임에 충실한 기능을 갖고 있으면서, 작업 수행시 작업을 요청하는구조임
- 객체지향적인 코드는 다른 오브젝트의 데이터를 가져와 작업하는 대신, 데이터를 갖고 있는 다른 오브젝트에게 작업을 요청하는 것임
- UserService는 User에게 “레벨 업그레이드” 요청하고, User는 Level에게 “다음 레벨”에 대한 정보를 요청하는 구조로 변경됐음
- 이 코드는 완벽하지는 않지만, 스프링 기능을 적용하기 적절한 구조로 만든것임
5.1.16 User 테스트
- User에 간단하지만 로직을 담은 메소드(upgradeLevel())을 추가했음
- 앞으로 계속 새로운 기능과 로직이 추가될 가능성이 있으니 테스트를 만들면 편함
- User 오브젝트는 스프링이 IoC 방식으로 관리하는 오브젝트가 아니기 때문에 스프링 테스트 컨텍스트를 사용하지 않아도 됨
- 테스트에 존재하는 upgradeLevel()은 Level 이늄에 정의된 모든 레벨을 가져와서 User에 저장하고, User의 upgradeLevel()을 실행해서 다음 레벨로 변하는지 확인
- 💡 같은 메소드 이름을 사용하기 때문에 헷갈리면 안됨
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();
}
}
}
5.1.17 UserServiceTest 개선
- UserServiceTest의 upgradeLevels()는 명확한 코드가 아님
- 기존의 checkLevel()로 넘기는 두 번째 파라미터를 업그레이드 기댓값이 아닌, bool 값으로 확인
//변경전
public class UserServiceTest {
...
@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));
}
}
//변경후
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()));
}
}
}
5.1.18 UserService 개선
- 로그인 및 추천 횟수가 애플리케이션 코드 와 테스트 코드에 중복돼서 나타남
- 정수형 상수로 만들어서 변경
public class UserService {
public static final int MIN_LOG_COUNT_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_LOG_COUNT_FOR_SILVER;
case SILVER:
return user.getRecommend() >= MIN_RECOMMEND_FOR_GOLD;
case GOLD:
return false;
default:
throw new IllegalArgumentException("Unknown Level: " + currentLevel);
}
}
...
}
import static com.ksb.spring.UserService.MIN_LOG_COUNT_FOR_SILVER;
import static com.ksb.spring.UserService.MIN_RECOMMEND_FOR_GOLD;
public class UserServiceTest {
...
@Before
public void setUp() {
users = Arrays.asList(
new User("k1", "k1", "k1", Level.BASIC,
MIN_LOG_COUNT_FOR_SILVER - 1, 0),
new User("k2", "k2", "k2", Level.BASIC,
MIN_LOG_COUNT_FOR_SILVER, 0),
new User("k3", "k3", "k3", Level.SILVER,
60, MIN_RECOMMEND_FOR_GOLD - 1),
new User("k4", "k4", "k4", Level.SILVER,
60, MIN_RECOMMEND_FOR_GOLD),
new User("k5", "k5", "k5", Level.GOLD,
100, Integer.MAX_VALUE)
);
...
}
}
5.1.19 업그레이드 정책
- 이벤트와 같이 업그레이드 정책일 일시적으로 변경되는 일이 발생할 수 있음
- 업그레이드 정책을 UserService에서 분리 하는 방법을 고려하면 됨
- 분리된 정책을 DI을 통해 UserService에 주입
- 이벤트 종료시 DI만 제거하면 됨
💡 책에도 실습 차원에서 하라고만 나와있음
public interface UserLevelUpgradePolicy {
boolean canUpgradeLevel(User user);
void upgradeLevel(User user);
}