자바에는 표준 스펙, 상용 제품, 오픈 소스를 통틀어서 사용 방법과 형식은 다르지만 기능과 목적이 유사한 기술이 존재한다. 환경과 상황에 따라 기술이 바뀌고, 그에 따른 API를 사용하고 다른 스타일의 접근 방법을 따라야 한다는 것은 매우 피곤한 일이다.
지금까지 만든 DAO
에 트랜잭션을 적용해보며 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있도록 지원하는지 알아보자.
현재 UserDao
에서는 소위 CRUD
라고 말하는 기능만 제공한다. 그 외에 어떠한 비즈니스 로직도 제공하지 않는다. 새로운 요구사항이 들어와서 단지 정보를 넣고 검색하는 것 외에도 정기적으로 사용자의 활동내역을 참고해서 레벨을 조정해주는 기능이 필요하다고 가정해보자.
Level
은 Basic
, Silver
, Gold
중 하나다.Basic
레벨이 되며, 이후 활동에 따라 조건부로 한 단계씩 업그레이드 된다.Silver
회원이 된다.Silver
레벨인 상태에서 추천을 30번 이상 받으면 Gold
회원이 된다.간단한 배치작업을 이용해 수행할 수 있다.
첫 요구사항을 충족하기 위해 Level
을 만들어야 한다고 가정하자. Level
을 저장할 때, DB에는 varchar
타입으로 선언하고, "BASIC"
, "SILVER"
, "GOLD"
로 저장할 수도 있겠지만, 약간의 메모리라도 중요한 케이스라고 가정하고, 각 레벨을 코드화해서 숫자로 넣는다고 가정하자.
숫자로 넣기로 했다고 가정하면, User
객체에 추가할 프로퍼티도 Integer
타입의 level
프로퍼티를 만드는 것이 좋을까? 상수적이며 범위가 한정적인 데이터를 코드화해서 사용할 때는 ENUM
을 이용해 구성하는 편이 좋다. 왜냐하면 단순히 1
, 2
, 3
과 같은 코드 값을 넣으면 작성자 외에는 1
이 어떤 Level
을 가리키는 것인지 알 방법이 없다.
의미가 명확하지 않은 숫자를 프로퍼티에 사용하면 타입이 안전하지 않아서 위험할 수 있다. 헷갈리기 너무 쉽다.
사실
Level
을 넣을 때, 코드화한 숫자보다는 문자열 그대로 넣는 것이 좋다고 생각한다. 숫자로 넣는 경우 본의 아니게 대소관계가 생길 수 있는데1=BASIC
2=SILVER
3=GOLD
로 점점 높은 등급이 되는 명확한 대소관계가 있는 상태에서1.5=BRONZE
가 낄 수는 없다. 만일2=BRONZE
로 하고 싶다면, 이미 데이터가 많이 쌓인 상태에서 기존에 쌓였던 데이터에 대해 전부 수정을 거쳐야 한다. 기존 데이터를 건들지 않고 살짝 추가만 하고 싶다면,4=BRONZE
와 같이 뭔가 탐탁치 않은 방식으로 해결해야 한다.
public class User {
private static final int BASIC = 1;
private static final int SILVER = 2;
private static final int GOLD = 3;
int level;
public setLevel(int level) {
this.level = level;
}
...
if (user1.getLevel() == User.BASIC) {
user1.setLevel(User.SILVER);
}
위는 ENUM
을 사용하지 않은 코드이다. 위와 같이 단순히 static int
형 상수로 정의하면 BASIC
, SILVER
, GOLD
와 같이 코드를 작성하여 의미있는 코드 작성은 가능하지만, 누군가 그냥 0
, 4
, 5
등 우리가 정의한 Level
의 코드 범위에 속하지 않는 값을 넣으면 속수무책으로 당하고 만다. 컴파일러 단계에서 체크해줄 수 없다.
물론 Setter
에서 if
문을 걸어서 BASIC
, SILVER
, GOLD
가 아닌 경우 Exception
을 날리도록 할 수도 있겠지만, 런타임에서 체크를 하는 것이어서 프로그램을 실행한 이후에나 잘못 입력했는지 알 수 있을 것이다.
정확하게 하려면 Level
의 도메인 자체를 ENUM
클래스로 분리해서 관리하는 편이 훨씬 깔끔하다. ENUM
클래스로 분리하면 자연적으로 허가되지 않은 단순한 int
값은 못들어오며, 추후에 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) {
return switch (value) {
case 1 -> BASIC;
case 2 -> SILVER;
case 3 -> GOLD;
default -> throw new AssertionError("Unknown value: " + value);
};
}
}
Level
도메인에 대한 책임을 맡을 훌륭한 ENUM
클래스가 생성되었다. 이제 컴파일 타임에 잘못된 int
값이 setLevel()
로 들어올 위험성은 줄였다.
public class User {
...
Level level;
int loginCount;
int recommendCount;
public Level getLevel() {
return level;
}
...
ENUM
클래스로 생성한 Level
과 함께 로그인 회수를 카운트할 loginCount
과 추천 회수를 카운트할 recommendCount
도 추가했다.
DB의
User
테이블에도 위 값이 담길 필드를 추가해주자.
Postgres
기준으로 위와 같은 타입과 이름으로 만들었다.
public User(String id, String name, String password, Level level, int loginCount, int recommendCount) {
this.id = id;
this.name = name;
this.password = password;
this.level = level;
this.loginCount = loginCount;
this.recommendCount = recommendCount;
}
생성자도 위와 같이 새로 만들어주었다.
public class UserDaoTest {
...
@BeforeEach
public void setUp() {
userDao.deleteAll();
this.user1 = new User("user1", "김똘일", "1234", Level.BASIC, 1 ,0);
this.user2 = new User("user2", "김똘이", "1234", Level.SILVER, 55, 10);
this.user3 = new User("user3", "김똘삼", "1234", Level.GOLD, 55, 10);
this.user4 = new User("user4", "김똘사", "1234", Level.BASIC, 1, 0);
}
기존의 픽스쳐들에도 Level
과 loginCount
, recommendCount
를 추가하여 넣어주었다.
private void checkSameUser(User user1, User user2) {
assertEquals(user1.getId(), user2.getId());
assertEquals(user1.getName(), user2.getName());
assertEquals(user1.getPassword(), user2.getPassword());
assertEquals(user1.getLevel(), user2.getLevel());
assertEquals(user1.getLoginCount(), user2.getLoginCount());
assertEquals(user1.getRecommendCount(), user2.getRecommendCount());
}
바꿨으니 테스트가 잘 작동하는지 확인해보면, 2가지 테스트가 실패하는 것을 볼 수 있다. DB에 새로 생겨난 컬럼이 클래스에 잘 매핑되지 않고 있는 것 같다.
public UserDaoJdbc() {
this.userRowMapper = (rs, rowNum) -> {
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.setLoginCount(rs.getInt("login_count"));
user.setRecommendCount(rs.getInt("recommend_count"));
return user;
};
}
먼저 매핑을 위와 같이 수정해준다. 이제 DB에서 데이터를 불러왔을 때 User
객체에는 잘 반영될 것이다.
public void add(User user) throws DuplicateUserIdException {
try {
this.jdbcTemplate.update("insert into users(id, name, password, level, login_count, recommend_count) values (?, ?, ?, ?, ?, ?)"
, user.getId()
, user.getName()
, user.getPassword()
, user.getLevel().intValue()
, user.getLoginCount()
, user.getRecommendCount()
);
} catch (DuplicateKeyException e) {
throw new DuplicateUserIdException(e);
}
}
반대 입장에서도 자바의 User
객체가 DB에 잘 매핑되도록 add()
메소드를 잘 수정해주었다. Level
필드의 경우, Level
객체 그대로 매핑은 불가능하니 .intValue()
라는 메소드를 이용해서 int
값으로 매핑해주었다.
반대로 DB에서 User
객체를 조회할 때는 int
값을 가져와서 Level.valueOf()
를 이용해서 Level
객체로 다시 전환해준다.
만일 이 부분에서 문자열로 작성된 SQL에 실수가 있었다면 어땠을까? 실행 전까지는 IDE내에서 어떠한 에러도 발견하지 못하고, 런타임 상태가 돼서야
BadSqlGrammerException
이라는 예외를 날렸을 것이다.JDBC가 사용하는 SQL은 컴파일 과정에서는 자동으로 검증이 되지 않는 단순 문자열에 불과하다. 그러나, 우리는 꼼꼼하게
UserDao
에서 생성한 모든 메소드에 대한 테스트를 작성해두었기 때문에 실제 서비스로 올라가기 전에 테스트만 돌려봤어도 해당 에러를 잡을 수 있었을 것이다.테스트를 작성하지 않았다면, 실 서비스 실행 중에 예외가 날아다녔을 것이고, 한참 후에 수동 테스트를 통해 메세지를 보고 디버깅을 해야 그제서야 겨우 오타를 확인할 수 있었을 것이다.
그때까지 진행한 빌드와 서버 배치, 서버 재시작, 수동 테스트 등에 소모한 시간은 낭비에 가깝다.
빠르게 실행 가능한 포괄적인 테스트를 만들어두면 이렇게 기능의 추가나 수정이 일어날 때 그 위력을 발휘한다.
사용자 관리 비즈니스 로직에 따르면 사용자 정보는 여러번 수정될 수 있다. 때때론 성능 최적화를 위해 수정되는 필드의 종류에 따라 여러 개의 수정용 DAO 메소드를 만들어야 할 때도 있지만, 아직 사용자 정보가 단순하고 필드도 몇개 되지 않고 수정이 자주 일어나지 않으므로 간단히 접근해보자.
@Test
@DisplayName("사용자 수정 기능 테스트")
public void update() {
userDao.add(user1);
user1.setName("오민규");
user1.setPassword("2345");
user1.setLevel(Level.GOLD);
user1.setLoginCount(1000);
user1.setRecommendCount(999);
userDao.update(user1);
User user1update = userDao.get(user1.getId());
checkSameUser(user1, user1update);
}
실패하는 테스트를 먼저 작성하자. 기본키인 id
를 제외한 나머지 내용을 바꾸고 DB에서 다시 해당 사용자를 조회하여 DB에 있는 내용과 객체에 있는 내용이 일치하여 DB에 잘 반영됐는지 확인한다.
public interface UserDao {
void add(User user);
User get(String id);
User getByName(String name);
List<User> getAll();
void deleteAll();
int getCount();
void update(User user1);
}
UserDao
에도 update()
메소드를 추가해주자.
@Override
public void update(User user) {
this.jdbcTemplate.update(
"update users set name = ?, password = ?, level = ?, login_count = ?, recommend_count = ? where id = ? "
, user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLoginCount(), user.getRecommendCount(), user.getId()
);
}
SQL 기본 문법만 알고 있으면 매우 쉽다.
테스트도 잘 작동하는 것을 확인했으니, 다음으로 넘어가자.
기본 수정 테스트는 성공했지만, 꼼꼼한 개발자라면 이 테스트에 뭔가 불만을 가지고 의심스럽게 코드를 다시 살펴봐야 한다. JDBC 개발에서 가장 많은 실수가 일어날만한 곳은 아무래도 컴파일러가 잡아주지 못하는 SQL 문자열 부분이다.
차라리 SQL의 문법을 틀렸다면, 런타임 도중에 예외가 날테지만 update
에서 where
절과 같은 부분을 빼먹으면 난감하다. 테스트는 정상적으로 동작하는데 결과는 이상한 경우가 발생할 수 있다.
이러한 문제를 해결하려면 두가지 방법이 있다.
첫번째 방법은 JdbcTemplate
의 update()
가 돌려주는 반환 값을 확인하는 것이다. JdbcTemplate
의 update()
는 UPDATE
, DELETE
와 같이 테이블의 내용에 영향향을 주는 SQL을 실행하면 영향받은 로우의 개수를 돌려준다.
where
를 사용하지 않았다면, 모든 row가 변경될 것이기 때문에, 1보다 큰 숫자가 나올 수 있다. 혹은 잘못된 조건을 사용했다면 아무런 row도 변경되지 않았기 때문에 0이 나올 것이다. 1인지 확인하는 코드를 하나 더 추가해주면 된다.
두번째 방법은 테스트를 보강해서 원하는 사용자 외의 정보는 변경되지 않았음을 직접 확인하는 것이다. 사용자를 두 명 등록해놓고, 그 중 하나만 수정한 뒤에 수정된 사용자와 수정하지 않은 사용자의 정보를 모두 확인하면 된다.
둘 다 적용하기에도 크게 귀찮지 않으니 둘 다 적용해보자.
public interface UserDao {
...
int update(User user);
}
인터페이스에서 정수형을 반환하도록 바꾸고
@Override
public int update(User user) {
return this.jdbcTemplate.update(
"update users set name = ?, password = ?, level = ?, login_count = ?, recommend_count = ? "
, user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLoginCount(), user.getRecommendCount()
);
}
update()
메소드에서도 결과 int
를 반환하도록 하자.
@Test
@DisplayName("사용자 수정 기능 테스트")
public void update() {
userDao.add(user1);
userDao.add(user2);
user1.setName("오민규");
user1.setPassword("2345");
user1.setLevel(Level.GOLD);
user1.setLoginCount(1000);
user1.setRecommendCount(999);
int updateCount = userDao.update(user1);
assertEquals(updateCount, 1);
User user1update = userDao.get(user1.getId());
checkSameUser(user1, user1update);
User user2same = userDao.get(user2.getId());
checkSameUser(user2, user2same);
}
테스트도 새로 작성했다. user2
는 데이터가 변화하지 않아야 한다.
일부러 where
문을 깜빡한 것처럼 테스트를 실행시키니
테스트에서 위와 같이 에러가 발생한다. updateCount
에 대한 검증을 빼도
위와 같이 변하지 않아야 되는 데이터가 변해서 또 에러가 난다.
다시 where
를 추가해주니,
정상적으로 테스트가 작동한다.
이제 레벨 관리 기능을 추가해야 한다. 레벨 관리 기능은 특정한 시간마다 돌아가며 현재 이용중인 회원 중 레벨업 조건을 만족한 회원의 레벨을 업그레이드 해줄 것이다.
그렇다면 이 사용자 관리 로직은 어디에 두는 것이 좋을까? UserDaoJdbc
는 적당하지 않다. DAO는 데이터를 어떻게 가져오고 조작할지 다루는 곳이지 비즈니스 로직을 두는 곳이 아니다. 사용자 관리 비즈니스 로직을 담을 클래스를 하나 추가해주자. 비즈니스 로직 서비스를 제공한다는 의미에서 클래스 이름은 UserService
로 한다.
UserService
는 User
도메인과 관련된 비즈니스 로직을 담당하게 되므로, User
객체의 내용과 DB에 있는 User
의 내용을 모두 건드려야 한다. UserService
는 UserDao
인터페이스 타입으로 userDao
빈을 DI받아서 쓸 것이다. 대문자로 시작하는 UserDao
는 인터페이스 이름이고, 소문자로 시작하는 userDao
는 빈 이름이니 잘 구분하자.
UserService
는 UserDao
의 구현 클래스가 변화해도 영향을 받으면 안된다. 데이터 액세스 로직이 바뀌었다고 해도 비즈니스 로직 코드를 수정하는 일이 있어선 안 된다. 따라서 DAO
의 인터페이스를 사용하고 DI를 적용하자. DI를 적용하려면 당연히 UserSerivce
도 스프링의 빈으로 등록돼야 한다.
아래와 같은 구조로 코드를 작성할 것이다.
public class UserService {
UserDao userDao;
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
}
UserDao
를 DI받을 수 있는 환경을 만들어놓았다.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
<property name="username" value="postgres" />
<property name="password" value="iwaz123!@#" />
<property name="driverClass" value="org.postgresql.Driver" />
<property name="url" value="jdbc:postgresql://localhost/toby_spring" />
</bean>
<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
<property name="dataSource" ref="dataSource" />
</bean>
<bean id="userDao" class="toby_spring.user.dao.UserDaoJdbc">
<property name="jdbcTemplate" ref="jdbcTemplate" />
</bean>
<bean id="userService" class="toby_spring.user.service.UserService">
<property name="userDao" ref="userDao" />
</bean>
</beans>
위와 같이 userService
를 빈으로 등록하고 userDao
빈을 userService
에 주입해주자.
먼저 간단히 UserService
가 정상적으로 userDao
를 주입받는지만 확인해보자.
@ExtendWith(SpringExtension.class) // (JUnit5)
@ContextConfiguration(locations="/spring/applicationContext.xml")
class UserServiceTest {
@Autowired UserService userService;
@Test
@DisplayName("userDao를 정상적으로 주입받았는지 확인")
public void isUserDaoNotEmpty() {
Assertions.assertNotNull(userService.userDao);
}
}
위와 같이 작성해주면, userService
내부에 있는 userDao
가 null
인지 간단하게 확인할 수 있다.
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
Boolean changed = null;
if (user.getLevel() == Level.BASIC && user.getLoginCount() >= 50) {
user.setLevel(Level.SILVER);
changed = true;
} else if (user.getLevel() == Level.SILVER && user.getRecommendCount() >= 30) {
user.setLevel(Level.GOLD);
changed = true;
} else if (user.getLevel() == Level.GOLD) {
changed = false;
} else {
changed = false;
}
if(changed) {
userDao.update(user);
}
}
}
어쩌다가 위와 같은 메소드를 만들었다고 생각해보자. 중복된 코드는 좀 나오고 책임의 분리도 잘 안되어있지만 비즈니스 로직이 명확히 보이고 아마 제대로 동작할 것이다.
모든 케이스를 체크하려면 각 레벨에서 업그레이드 되는 경우와 업그레이드 되지 않는 경우를 나눠서 생각해보면 된다.
레벨은 3가지가 있고 경우는 2가지가 있어서 총 6가지의 경우의 수가 나오는데, GOLD
의 경우 더이상 다음 레벨이 없어 업그레이드가 불가능하니 총 5가지만 체크해보면 된다.
@BeforeEach
public void setUp() {
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, 100)
);
}
테스트 픽스쳐는 위와 같이 만들어주었다. loginCount
는 50
일 때 업그레이드 기준이고, recommendCount
는 30
일 때 업그레이드 기준이어서 일부러 기준의 경계값을 이용한 데이터를 만들었다.
보통 잘못된 현상이 발생할 때는 경계값 근처에서 많이 일어나므로, 경계값으로 테스트하는 습관은 좋은 습관이다. BASIC
과 SILVER
는 각각 업그레이드가 가능한 경우, 불가능한 경우 모든 경우의 수를 다 만들어주었다.
@Test
@DisplayName("사용자 레벨 업그레이드 테스트")
public void upgradeLevels() {
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());
Assertions.assertEquals(userUpdate.getLevel(), expectedLevel);
}
위와 같이 테스트를 작성하고 각각 데이터가 올바르게 레벨 업그레이드가 됐는지, GOLD
인 경우 그대로인지를 확인했다.
테스트는 잘 통과한다.
처음 가입하는 사용자는 기본적으로 BASIC
레벨이어야 한다는 요구사항을 충족시켜보자.
public void add(User user) throws DuplicateUserIdException {
try {
this.jdbcTemplate.update("insert into users(id, name, password, level, login_count, recommend_count) values (?, ?, ?, ?, ?, ?)"
, user.getId()
, user.getName()
, user.getPassword()
, user.getLevel().intValue()
, user.getLoginCount()
, user.getRecommendCount()
);
} catch (DuplicateKeyException e) {
throw new DuplicateUserIdException(e);
}
}
현재는 단순히, 받은 Level
을 적용시키도록 하고 있다. 그렇다면 저기에 그냥 만일 레벨 정보가 null
이라면, Level.BASIC
을 넣도록 할까? 그건 옳지 않을 것이다. UserDao
는 온전히 데이터의 CRUD
를 다루는 데만 치중하는 것이 옳고, 비즈니스 로직이 섞이는 것은 바람직하지 않다.
차라리 User
클래스에서 level
필드를 기본 값으로 Level.BASIC
으로 초기화해보자. 하지만 처음 가입할 때를 제외하면 무의미한 정보인데, 단지 이 로직을 담기 위해 클래스에서 직접 초기화하는 것은 문제가 있어 보이긴 한다.
그렇다면 UserService
에 이 로직을 넣으면 어떨까? UserDao
의 add()
메소드는 사용자 정보를 담은 User
오브젝트를 받아서 DB에 넣어주는 데 충실한 역할을 한다면, UserService
에도 add()
를 만들어두고 사용자가 등록될 때 적용할만한 비즈니스 로직을 담당하게 하면 될 것이다.
UserDao
와 같이 리포지토리 역할을 하는 클래스를 컨트롤러에서 바로 쓰냐 마냐에 대한 논쟁이 있는데, 바로 쓰면 아무런 비즈니스 로직이 들어가지 않은 순수한 CRUD의 의미일 것이다.
먼저 테스트부터 만들어보자. UserService
의 add()
를 호출하면 레벨이 BASIC
으로 설정되는 것이다. 그런데, UserService
의 add()
에 전달되는 User
오브젝트에 Level
값이 미리 설정되어 있다면, 설정된 값을 이용하도록 하자.
그렇다면 테스트 케이스는 두가지 종류가 나올 수 있다.
BASIC
레벨을 갖는다.각각 add()
메소드를 호출하고 결과를 확인하도록 만들자.
가장 간단한 방법은 UserService
의 add()
메소드를 호출할 때 파라미터로 넘긴 User
오브젝트에 level
필드를 확인해보는 것이고, 다른 방법은 UserDao
의 get()
메소드를 이용해서 DB에 저장된 User
정보를 가져와 확인하는 것이다. 두가지 다 해도 좋고, 후자만 해도 괜찮을 것 같다.
UserService
는 UserDao
를 통해 DB에 사용자 정보를 저장하기 때문에 이를 확인해보는 게 가장 확실한 방법이다. UserService
가 UserDao
를 제대로 사용하는지도 함께 검증할 수 있고, 디폴트 레벨 설정 후에 UserDao
를 호출하는지도 검증되기 때문이다.
@Test
@DisplayName("기본 레벨이 Level.BASIC으로 설정되는지 테스트")
public void defaultLevelIsBasic() {
User userWithLevel = users.get(3); //SILVER
User userWithoutLevel = users.get(4);
userWithoutLevel.setLevel(null);
userService.add(userWithLevel);
userService.add(userWithoutLevel);
User dbUserWithLevel = userDao.get(userWithLevel.getId());
User dbUserWithoutLevel = userDao.get(userWithoutLevel.getId());
Assertions.assertEquals(dbUserWithLevel.getLevel(), userWithLevel.getLevel());
Assertions.assertEquals(dbUserWithoutLevel.getLevel(), Level.BASIC);
}
public void add(User user) {
// 간단히 level이 null이라면, Level.BASIC 삽입
if(user.getLevel() == null) {
user.setLevel(Level.BASIC);
}
userDao.add(user);
}
위 코드의 핵심은 기존에 픽스쳐에 존재하던 유저에 2개에 대해
level
을 null
로 설정한 뒤에 userService
를 통해 .add()
하고 level
이 BASIC
인지 확인한다.level
을 Level.SILVER
인 상태 그대로 userService
를 통해 .add()
하여 level
이 그대로 SILVER
인지 확인한다.테스트를 돌려보면 성공이고, 다만 테스트가 조금 복잡한 것이 흠이다. 간단한 비즈니스 로직을 담은 코드를 테스트하기 위해 DAO
와 DB까지 모두 동원되는 점이 조금 불편하다. 이런 테스트는 깔끔하고 간단하게 만드는 방법이 있는데 뒤에서 다시 다뤄보자.
어느정도 요구사항은 맞춰놨지만, 아직 코드가 깔끔하지 않게 느껴진다. 다음 사항들을 체크해보자.
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
Boolean changed = null;
if (user.getLevel() == Level.BASIC && user.getLoginCount() >= 50) {
user.setLevel(Level.SILVER);
changed = true;
} else if (user.getLevel() == Level.SILVER && user.getRecommendCount() >= 30) {
user.setLevel(Level.GOLD);
changed = true;
} else if (user.getLevel() == Level.GOLD) {
changed = false;
} else {
changed = false;
}
if(changed) {
userDao.update(user);
}
}
}
for
루프 속에 들어있는 if/else
블록이 겹쳐 읽기 불편하다.코드가 깔끔해보이지 않는 이유는 이렇게 성격이 다른 여러가지 로직이 섞여있기 때문이다.
user.getLevel() == Level.BASIC
은 레벨이 무엇인지 파악하는 로직이다.user.getLoginCount() >= 50
은 업그레이드 조건을 담은 로직이다.user.setLevel(Level.SILVER);
는 다음 단계의 레벨이 무엇인지와 레벨 업그레이드를 위한 작업은 어떤 것인지가 함께 담겨있다.changed = true;
는 이 자체로는 의미가 없고, 단지 멀리 떨어져 있는 userDao.update(user);
의 작업이 필요함을 알려주는 역할이다.잘 살펴보면 관련이 있지만, 사실 성격이 조금 다른 것들이 섞여있거나 분리돼서 나타나는 구조다.
Level ENUM
도 수정해야 하고, upgradeLevels()
의 레벨 업그레이드 로직을 담은 코드에 if
조건식과 블록을 추가해줘야 한다. user.setLevel(다음레벨);
뒤에 추가적인 코드를 작성해주어야 할 것이다. 그러면 점점 메소드의 if
문 블록은 커진다.if
조건과 맞지 않으니 else
로 이동하는데, 성격이 다른 두 가지 경우가 모두 한 곳에서 처리되는 것은 뭔가 이상하다.if()
내부에 들어갈 내용이 방대하게 커질 수 있다.아마 upgradeLevels()
코드 자체가 너무 많은 책임을 떠안고 있어서인지 전반적으로 변화가 일어날수록 코드가 지저분해진다는 것을 추측할 수 있다. 지저분할수록 찾기 힘든 버그가 숨어들어갈 확률이 높아질 것이다.
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
if(canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
위는 upgradeLevels()
에서 기본 작업 흐름만 남겨둔 코드이다. 이 코드는 한 눈에 읽기에도 사용자 정보를 받아서 레벨 업그레이드를 할 수 있으면 레벨 업그레이드를 한다. 명확하다.
이는 구체적인 구현에서 외부에 노출할 인터페이스를 분리하는 것과 마찬가지 작업을 코드에 한 것이다.
이제 인터페이스화된 메소드들을 하나씩 구현해보자.
private boolean canUpgradeLevel(User user) {
Level currentLevel = user.getLevel();
return switch(currentLevel) {
case BASIC -> user.getLoginCount() >= 50;
case SILVER -> user.getRecommendCount() >= 30;
case GOLD -> false;
default -> throw new IllegalArgumentException("Unknown Level: " + currentLevel);
};
}
canUpgradeLevel()
의 요구사항은 해당 사용자에 대한 레벨 업그레이드 가능 여부를 확인하고 그 결과를 반환하는 것이다.
switch
문으로 레벨을 구분하고 각 레벨에 대한 업그레이드 조건을 체크하고 업그레이드가 가능한지에 따라 true/false
를 반환해준다.
또, 등록되지 않은 레벨에 대해 메소드를 수행할 시에는 IllegalArgumentException
이 발생하기 때문에 해당 등급에 대한 로직 처리를 하지 않았음을 쉽게 알 수 있다.
private void upgradeLevel(User user) {
Level currentLevel = user.getLevel();
switch (currentLevel) {
case BASIC -> user.setLevel(Level.SILVER);
case SILVER -> user.setLevel(Level.GOLD);
default -> throw new IllegalArgumentException("Can not upgrade this level: " + currentLevel);
}
userDao.update(user);
}
upgradeLevel()
의 요구사항은 해당 사용자에 대한 레벨 업그레이드를 진행하는 것이다.
위와 같이 작성하여 보기엔 깔끔해 보이지만, 여기서도 무언가 맘에 안드는 점이 있다.
level
필드만을 손보지만, 나중에 포인트 같은 개념이 생겨서 레벨 업그레이드 보너스 포인트 같은 것을 증정해야된다고 생각해보자. case
문 뒤의 블록 내용이 많이 늘어날 것이다.업그레이드 후의 레벨이 무엇인지 결정하는 책임은 Level ENUM
이 갖는 것이 맞지 않을까? 레벨의 순서에 대한 책임을 UserService
에게 위임하지 말자.
public enum Level {
// 초기화 순서를 3, 2, 1 순서로 하지 않으면 `SILVER`의 다음 레벨에 `GOLD`를 넣는데 에러가 발생한다.
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 next;
}
public int intValue() {
return value;
}
public static Level valueOf(int value) {
return switch (value) {
case 1 -> BASIC;
case 2 -> SILVER;
case 3 -> GOLD;
default -> throw new AssertionError("Unknown value: " + value);
};
}
}
위와 같이 업그레이드 순서에 대한 책임을 Level enum
에 맡겼다. 이제 다음 레벨이 무엇인지 알고 싶다면, .nextLevel()
메소드를 출력해보면 된다. 이제 다음 단계의 레벨이 무엇인지 일일이 if
문에 담아둘 필요가 없다.
이제 사용자 정보가 바뀌는 부분을 UserService
메소드에서 User
로 옮겨보자. User
는 사용자 정보를 담고 있는 단순한 자바빈이긴 하지만 User
도 엄연히 자바 오브젝트이고 내부 정보를 다루는 기능이 있을 수 있다. UserService
가 일일이 레벨 업그레이드 시에 User
의 어떤 필드를 수정해야 하는지에 대한 로직을 갖고 있기 보다는 User
에게 레벨 업그레이드를 해야 하니 정보를 변경하라고 요청하는 편이 낫다.
public void upgradeLevel() {
Level nextLevel = this.level.nextLevel();
if (nextLevel == null) {
throw new IllegalStateException(this.level + "은 업그레이드가 불가능합니다.");
} else {
this.level = nextLevel;
}
}
UserService
의 canUpgradeLevel()
메소드에서 업그레이드 가능 여부를 미리 판단해주긴 하지만, User
오브젝트를 UserService
만 사용한다는 보장은 없으므로, 스스로 예외상황에 대한 검증 기능을 갖고 있는 편이 안전하다.
Level enum
은 다음 레벨이 없는 경우에는 nextLevel()
에서 null
을 반환한다. 따라서 이 경우에는 User
의 레벨 업그레이드 작업이 진행돼서는 안되므로, 예외를 던져야 한다.
애플리케이션의 로직을 바르게 작성하면 이런 경우는 아예 일어나지 않겠지만, User
오브젝트를 잘못 사용하는 코드가 있다면 확인해줄 수 있으니 유용하다.
User
에 업그레이드 작업을 담당하는 독립적인 메소드를 두고 사용할 경우, 업그레이드 시 기타 정보도 변경이 필요해졌을 때, 그 장점이 무엇인지 알 수 있을 것이다. 이를테면 마지막으로 업그레이드 된 시점을 기록하고 싶다면, lastUpgraded
필드를 추가하고 this.lastUpgraded = new Date();
와 같은 코드를 추가함으로써, 쉽게 동작을 더할 수 있다.
private void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
User
객체에 레벨을 업그레이드 하는 책임을 주어 코드가 한결 깔끔해졌다. 이전의 if
문이 많이 들어있던 코드를 생각하면, 간결하고 작업 내용이 명확하게 드러난다. 각 오브젝트가 해야 할 책임도 깔끔하게 분리됐다.
지금 개선한 코드를 전체적으로 살펴보면, 각 오브젝트와 메소드가 각각 자기 몫의 책임을 맡아 일을 하는 구조로 만들어졌음을 알 수 있을 것이다.
UserService
, User
, Level
이 내부 정보를 다루는 자신의 책임에 충실한 기능을 갖고 있으면서 필요가 생기면 이런 작업을 수행해달라고 서로 요청하는 구조이다.
각자 자기 책임에 충실한 작업만 하고 있으니 코드를 이해하기도 쉽다. 또, 변경이 필요할 때 어디를 수정해야 할지도 쉽게 알 수 있다. 잘못된 요청이나 작업을 시도했을 때 이를 확인하고 예외를 던져줄 준비도 다 되어 있다.
각각을 독립적으로 테스트하도록 만들면 테스트 코드도 단순해진다.
각 클래스가 자신이 갖는 책임에 대한 코드만 작성하도록 노력했다.
객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청한다.오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이기도 하다.
처음 구현했던 UserService
의 upgradeLevels()
메소드는 User
오브젝트에서 데이터를 가져와서 그것을 가지고 User
오브젝트나 Level enum
이 해야 할 작업을 대신 수행하고 직접 User
오브젝트의 데이터를 변경해버렸다. 이보다는 UserService
는 User
에게 레벨 업그레이드 작업을 해달라
고 요청하고, 또 User
는 Level
에게 다음 레벨이 무엇인지 알려달라
고 요청하는 방식으로 동작하게 하는 것이 바람직하다.
만일,
BRONZE
레벨을 BASIC
과 SILVER
사이에 추가하라.BRONZE
에서 SILVER
로 업그레이드하는 조건은 로그인 횟수 80번이다.보자마자 Level enum
에 있는 다음 레벨
과 관련된 코드와 UserService
에 있는 canUpgradeLevel()
메소드를 떠올릴 수 있다면 성공적일 것 같다.
위와 같은 요구사항이 들어오면 먼저 레벨 변경은 User
의 upgradeLevel()
메소드에서 수행하는 것이니 User
의 필드에 최근 레벨 변경 날짜를 추가하고 lastLevelUpdated = new LocalDateTime()
등의 코드를 추가하는 것으로 해결할 수 있을 것이다.
로그를 남기는 것은 User
의 상태와는 전혀 관련이 없으니 UserService
의 upgradeLevel()
에서 DB 업데이트 이후에 것이 바람직할 것이다.
이렇게 책임에 맞게 코드를 작성하면 변경 후에도 코드는 여전히 깔끔하고 코드를 이해하는데도 어려움이 없을 것이다.
물론 지금까지 진행한 UserService
의 리팩토링과 그 결과로 만들어진 코드가 정답이라거나 완벽한 것은 아니다. 애플리케이션의 특성과 구조, 발전 방향 등에 따라 더 세련된 설계도 가능하다. 좀 더 객체지향 적인 특징이 두드러지게 구조를 바꿀 수도 있다. 현재 UserService
의 코드는 5장에서 설명하려는 스프링의 기능을 적용하기 적절한 구조로 만들어둔 것 뿐이다.
항상 코드를 더 깔끔하고 유연하면서 변화에 대응하기 쉽고 테스트하기에 좋게 만드려고 노력해야 함을 기억하고 다음으로 넘어가자.
방금 User
에 간단하지만 로직을 담은 메소드를 추가했다. 앞으로도 계속 새로운 기능과 로직이 추가될 가능성이 있으니 테스트를 만들어두자.
@Test
@DisplayName("유저 레벨 업그레이드 테스트")
public void upgradeLevel() {
Level[] levels = Level.values();
for (Level level : levels) {
if(level.nextLevel() == null) continue;
user.setLevel(level);
user.upgradeLevel();
Assertions.assertEquals(user.getLevel(), level.nextLevel());
}
}
@Test
@DisplayName("예외 테스트 - 다음 레벨이 없는 레벨을 업그레이드 하는 경우")
public void cannotUpgradeLevel() {
Level[] levels = Level.values();
Assertions.assertThrows(IllegalStateException.class, () -> {
for (Level level : levels) {
if(level.nextLevel() != null) continue;
user.setLevel(level);
user.upgradeLevel();
}
});
}
두가지에 대한 테스트를 해봤다.
핵심은 User
클래스를 테스트할 때는 딱히 스프링 프레임워크에 대한 의존성이 없어서 스프링 테스트가 아닌 간단한 유닛테스트로 테스팅을 진행할 수 있다는 점이다.
굳이 이렇게 까지 테스트를 하는 이유는 나중에 있을 변화에 대비하기 위해서이다. 나중에 upgradeLevel()
메소드에 좀 더 복잡한 기능이 추가됐을 때도 이 테스트를 확장해 사용할 수 있다.
기존에는 다음 레벨에 대한 정보를 Level.nextLevel()
에서 가져오는 것이 아니라, 직접 Level.SILVER
와 같은 방식으로 넣어주었다. 이러한 사소한 것도 사실 중복이다.
@Test
@DisplayName("사용자 레벨 업그레이드 테스트")
public void upgradeLevels() {
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);
}
위와 같이 각각의 User
에 대해 checkLevel()
에 Level
을 직접 넣어준 것을 볼 수 있다. 레벨이 변경되거나 추가되면 테스트도 따라서 수정해주어야 했다.
@Test
@DisplayName("사용자 레벨 업그레이드 테스트")
public void upgradeLevels() {
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 userOrigin, boolean upgraded) {
User userUpdate = userDao.get(userOrigin.getId());
Assertions.assertEquals(
userOrigin.getLevel().nextLevel() == userUpdate.getLevel()
, upgraded);
}
위와 같이 테스트를 변경했다. 다음 레벨이 무엇인지에 대한 책임은 Level
에서 담당하고, 우리는 그 부분을 더이상 하드코딩하지 않는다.
checkLevel()
메소드의 파라미터도 upgraded
로 바뀌어 단순히 업그레이드 되었는지를 판단하고, 정확히 다음 레벨이 어떤 레벨인지는 Level enum
에게 맡긴다.
또한 레벨이 업그레이드 되는지에 대해서만 관심을 가지므로 메소드명도 명확하게 바꾸고, 업그레이드가 되는지 안되는지에 대해 확실히 true/false
로 구분했다.
private boolean canUpgradeLevel(User user) {
Level currentLevel = user.getLevel();
return switch(currentLevel) {
case BASIC -> user.getLoginCount() >= 50;
case SILVER -> user.getRecommendCount() >= 30;
case GOLD -> false;
default -> throw new IllegalArgumentException("Unknown Level: " + currentLevel);
};
}
...
new User("bumjin", "박범진", "p1", Level.BASIC, 49, 0)
, new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0)
...
위의 50
이 의미하는 것은 정확히 말하면 BASIC
레벨 유저가 SILVER
레벨이 되기 위해서 필요한 로그인 횟수이다. 현재 상황에서는 SILVER
레벨이 되기 위해 필요한 로그인 횟수에 변경이 생기면 둘 다 바꿔줘야 한다. 또 이렇게 의미론적으로도 불명확한 상수는 리팩토링해주자.
public class UserService {
UserDao userDao;
public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_COUNT_FOR_GOLD = 30;
...
private boolean canUpgradeLevel(User user) {
Level currentLevel = user.getLevel();
return switch(currentLevel) {
case BASIC -> user.getLoginCount() >= MIN_LOGIN_COUNT_FOR_SILVER;
case SILVER -> user.getRecommendCount() >= MIN_RECOMMEND_COUNT_FOR_GOLD;
case GOLD -> false;
default -> throw new IllegalArgumentException("Unknown Level: " + currentLevel);
};
}
}
import static toby_spring.user.service.UserService.*;
class UserServiceTest {
@Autowired UserService userService;
UserDao userDao;
List<User> users;
@BeforeEach
public void setUp() {
this.userDao = this.userService.userDao;
userDao.deleteAll();
users = Arrays.asList(
new User("bumjin", "박범진", "p1", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER - 1, 0)
, new User("joytouch", "강명성", "p2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0)
, new User("erwins", "신승한", "p3", Level.SILVER, MIN_LOGIN_COUNT_FOR_SILVER, MIN_RECOMMEND_COUNT_FOR_GOLD - 1)
, new User("madnite1", "이상호", "p4", Level.SILVER, MIN_LOGIN_COUNT_FOR_SILVER, MIN_RECOMMEND_COUNT_FOR_GOLD)
, new User("green", "오민규", "p5", Level.GOLD, 100, 100)
);
}
...
위와 같이 픽스처에 들어가는 숫자들도 상수로 다 바꿔주었다. 이제 해당 상수만 조정하면 SILVER
레벨이 되기 위한 로그인 횟수를 테스트까지 한번에 조정할 수 있다. 의미론적으로도 훨씬 명확하다.
좀 더 욕심을 내자면 레벨을 업그레이드하는 정책을 유연하게 변경할 수 있도록 개선하는 것도 생각해볼 수 있다. 연말 이벤트나 새로운 서비스 홍보기간 중에는 렙레업그레이드 정책을 다르게 적용할 필요가 있을 수도 있다.
그럴 때마다 중요한 사용자 관리 로직을 담은 UserService
의 코드를 직접 수정했다가 이벤트 기간이 끝나면 다시 이전 코드로 수정하는 것은 상당히 번거롭고 위험한 방법이다.
이런 경우 업그레이드 정책을 UserService
에서 분리하는 방법을 고려할 수 있다. 분리된 업그레이드 정책을 담은 오브젝트는 DI를 통해 UserService
에 주입한다.
스프링 설정을 통해서 평상시 정책을 구현한 클래스를 UserService
에서 사용하게 하다가 이벤트 때는 새로운 업그레이드 정책을 담은 클래스를 따로 만들어서 DI해주면 된다. 이벤트가 끝나면 기존 업그레이드 정책 클래스로 다시 변경해준다.
public interface UserLevelUpgradePolicy {
boolean canUpgradeLevel(User user);
void upgradeLevel(User user);
}
public class OrdinaryUserLevelUpgradePolicy implements UserLevelUpgradePolicy {
public OrdinaryUserLevelUpgradePolicy() {
}
@Override
public boolean canUpgradeLevel(User user) {
Level currentLevel = user.getLevel();
return switch(currentLevel) {
case BASIC -> user.getLoginCount() >= UserService.MIN_LOGIN_COUNT_FOR_SILVER;
case SILVER -> user.getRecommendCount() >= UserService.MIN_RECOMMEND_COUNT_FOR_GOLD;
case GOLD -> false;
default -> throw new IllegalArgumentException("Unknown Level: " + currentLevel);
};
}
@Override
public void upgradeLevel(User user) {
user.upgradeLevel();
}
}
public class EventUserLevelUpgradePolicy implements UserLevelUpgradePolicy {
private final int bonusCount;
public EventUserLevelUpgradePolicy() {
this.bonusCount = 10;
}
public EventUserLevelUpgradePolicy(int bonusCount) {
this.bonusCount = bonusCount;
}
@Override
public boolean canUpgradeLevel(User user) {
Level currentLevel = user.getLevel();
return switch(currentLevel) {
case BASIC -> user.getLoginCount() + bonusCount >= UserService.MIN_LOGIN_COUNT_FOR_SILVER;
case SILVER -> user.getRecommendCount() + bonusCount >= UserService.MIN_RECOMMEND_COUNT_FOR_GOLD;
case GOLD -> false;
default -> throw new IllegalArgumentException("Unknown Level: " + currentLevel);
};
}
@Override
public void upgradeLevel(User user) {
user.upgradeLevel();
}
}
책에는 나와있지 않지만, 위와 같은 형태로 작성해보았다.
테스트도 무난히 잘 통과하고 실패한다. 이벤트의 경우에는 10번의 카운트를 보너스로 줬기 때문에 실패해야 정상이다.