지금까지 만들었던 UserDao는 User 오브젝트에 담겨 있는 사용자 정보를 등록, 조, 수정, 삭제하는 CRUD라고 불리는 가장 기초적인 작업만 가능합니다. 여기에 사용자 레벨을 관리할 수 있는 다음과 같은 비즈니스 로직을 추가해보겠습니다.
- 사용자의 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나입니다.
- 사용자가 처음 가입하면 BASIC 레벨이 되며, 이후 활동에 따라 다음 조건에 맞추어 한 단계씩 업그레이드될 수 있습니다.
- 가입 후 50회 이상 로그인하면 SILVER 회원이 됩니다.
- SILVER 레벨인 상태에서 추천을 30번 이상 받으면 GOLD 회원이 됩니다.
- 사용자 레벨의 변경 작업은 주기를 가지고 일괄적으로 진행됩니다. 변경 작업 전에는 조건을 충족해도 레벨의 변경이 일어나지 않습니다.
✅ Enum 필드 추가
DB에 varchar 타입으로 컬럼을 생성하고 BASIC, SILVER, GOLD 문자열을 사용자별로 저장할 수도 있겠지만, 각 레벨을 코드화해서 숫자로 저장하면 DB용량도 많이 차지 하지 않고 가벼워져서 좋습니다. 하지만 만약 로직 상에서 float나 double과 같이 정수가 아닌 수가 잘못해서 들어가게 된다면 레벨이 엉뚱하게 바뀌는 심각한 버그가 만들어질 수도 있기 때문에 Java 5부터 지원하면 Enum 클래스를 사용하면 좋습니다.
- Enum, Level
package com.jhcode.spring.ch5.user.domain; public enum Level { BASIC(1) , SILVER(2), GOLD(3); private final int value; //생성자 -> DB에 저장할 값인 필드를 초기화하는 역할 Level(int value){ this.value = value; } //필드 value의 값을 가져오는 메소드 public int getIntValue() { 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 타입의 객체이기 때문에 안전하게 사용할 수 있습니다.
Level
열거형(enum)의 필드 BASIC, SILVER, GOLD는 생성자를 통해 초기화되는 인스턴스입니다. 이러한 인스턴스는Level.
BASIC
,Level.
SILVER
,Level.
GOLD
와 같은 방식으로 접근할 수 있습니다. 생성자는Level
열거형의 각 요소에 대한 초기화를 담당합니다. 이 경우int
타입의value
필드를 초기화하기 위해 사용됩니다.BASIC(1)
은BASIC
요소의 생성자를 호출하여value
필드에1
을 전달하여 초기화하는 것을 의미합니다.SILVER(2)
와GOLD(3)
도 마찬가지로 각각의 요소에 대한 생성자를 호출하여value
필드를 초기화합니다.따라서
Level.
BASIC
,Level.
SILVER
,Level.
GOLD
를 사용할 때는 생성자가 호출되어value
필드가 초기화되며, 해당 값을intValue()
메소드를 통해 얻을 수 있습니다.
- User
package com.jhcode.spring.ch5.user.domain; public class User { String id; String name; String password; Level level; int login; int recommend; // == 기본 생성자 ==// public User() { } // == 테스트를 쉽게 하기 위해 파라미터가 있는 생성자 ==// 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; } public String getId() {return id;} public void setId(String id) {this.id = id;} public String getName() {return name;} public void setName(String name) {this.name = name;} public String getPassword() {return password;} public void setPassword(String password) {this.password = password;} public Level getLevel() {return level;} public void setLevel(Level level) {this.level = level;} public int getLogin() {return login;} public void setLogin(int login) {this.login = login;} public int getRecommend() {return recommend;} public void setRecommend(int recommend) {this.recommend = recommend;} }
필드로 Level level, int login, int recommend를 추가했습니다. 각각 사용자의 레벨과 로그인 횟수와 추천수를 의미합니다. 생성자의 파라미터로 받아 해당 필드를 초기화할 수 있도록 했고, getter, setter 메소드도 작성하였습니다.
- DB column 추가
필드명 타입 설정 Level tinyint Login int Not Null Recommend int Not Null 위의 표와 같이 DB의 컬럼을 추가합니다.
ALTER TABLE users ADD COLUMN level TINYINT NOT NULL, ADD COLUMN login INT NOT NULL, ADD COLUMN recommend INT NOT NULL;
위는 MariaDB으로 쿼리문을 작성하여 컬럼을 추가한 것입니다.
✅ UserDaoTest 수정
Enum 필드와 추가된 필드들이 DB에 제대로 저장되는지 확인하기 위해서 테스트 코드를 수정하겠습니다. 테스트 코드를 먼저 수정해두고, 해당 테스가 제대로 작동할 수 있도록 나머지 코드를 작성해보겠습니다. 이를 앞에서 TDD, 테스트 주도 개발이라고 불렀습니다.
- UserDaoTest, setUp()
@BeforeEach public void setUp() { System.out.println("setUp():" + this); DataSource dataSource = new SingleConnectionDataSource( "jdbc:mariadb://localhost:3306/toby?characterEncoding=UTF-8", "root", "1234", true); dao = new UserDaoJdbc(); dao.setDataSource(dataSource); user1 = new User("user1", "one", "1111", Level.BASIC, 1, 1); user2 = new User("user2", "two", "2222", Level.SILVER, 2, 2); user3 = new User("user3", "three", "3333", Level.GOLD, 3, 3); }
User의 필드인 Level, login, recommend를 생성자를 통해 초기화하기 때문에, 테스트 코드를 수행하기 전에 실행한느
setUp()
메소드를 수정하였습니다. user1, 2, 3 객체를 생성할 때, 생성자 파라미터가 추가되었습니다.
- UserDaoTest, checkSameUser()
//DB에서 가져온 값과, 로직에서 생성한 값이 같은지 확인하는 코드 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.getLevel(), user2.getLogin()); assertEquals(user1.getRecommend(), user2.getRecommend()); }
checkSameUser()
메소드는getAll()
메소드에서 DB에서 가져온 User의 정보를 비교하여 그 값이 같은지 확인하는 코드입니다. User의 필드가 추가되었으므로 추가된 필드까지 테스트할 수 있도록 코드를 추가하였습니다.
✅ UserDaoJdbc 수정
미리 준비된 테스트 코드가 성공하도록 UserDaoJdbc 클래스를 수정해 보도록 하겠습니다.
- userRowMapper()
//==RowMapper 객체를 생성하기 위한 메소드==// private RowMapper<User> userRowMapper() { return ((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.setLogin(rs.getInt("login")); user.setRecommend(rs.getInt("recommend")); return user; }); }
RowMapper 객체를 생성하는
userRowMapper()
메소드는 DB에서 가져온 User의 정보를 User 객체로 바인딩하는 역할을 맡고 있습니다. 추가된 필드에 따라 ResultSet 객체에서 컬럼의 값을 가져와 User 객체에 대입하였습니다. 한가지 특이한 것은 Level은 DB에서 int 값으로 저장되고 있습니다. 따라서 Level 클래스의 Static 메소드인valueOf()
메소드를 사용하여 int 값은 switch-case 문을 통해 Enum 클래스의 상수로 반환하여 저장하도록 구성되어 있습니다.
- add()
//== DB에 user 추가 ==// public void add(final User user) { String sql = "INSERT INTO users(id, name, password, level, login, recommend) " + "VALUES(?,?,?,?,?,?)"; this.jdbcTemplate.update(sql, user.getId(), user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend()); }
가장 먼저 DB의 컬럼이 늘어났기 때문에 쿼리문이 수정되었습니다. 컬럼이 추가되고, 와일드 카드가 늘어났으며, 이에 따라 user에서 와일드 카드에 값이 바인딩될 수 있도록 알맞은 값을 가져왔습니다. 여기서도 Level Enum은 객체로 인식되기 때문에
inValue()
메소드를 사용해서 객체의 필드의 int 값을 가져오도록 했습니다.
- RowMapper 객체를 생성하기 위한 익명 클래스 사용
//==RowMapper 객체를 생성하기 위한 익명 클래스 사용==// 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; } };
앞에서는 RowMapper 객체를 생성하기 위해서
userRowMapper()
메소드를 통해 람다식으로 구현했다면, 이번에는 익명 클래스를 사용해서 구현해보았습니다. 해당 객체를 사용하기 위해userRowMapper()
메소드를 사용했던getAll()
메소드와get()
메소드를 아래와 같이 수정하면 됩니다.//== DB에서 id에 해당하는 user 정보 검색 ==// public Optional<User> get(String id) { String sql = "SELECT * FROM users WHERE id = ?"; try (Stream<User> stream = jdbcTemplate.queryForStream(sql, this.userMapper, id)) { //...생략 //== 테이블에 있는 전체 User 정보 가져오기 public List<User> getAll() { String sql = "SELECT * FROM users ORDER BY id DESC"; return this.jdbcTemplate.query(sql, this.userMapper); }
이제 테스트를 돌리면 아래와 같이 성공적인 테스트가 됩니다.
✅ 사용자 수정 기능 추가
사용자 관리 비즈니스 로직에 따르면 사용자 정보는 여러 번 수정될 수 있습니다. 기본키인 id를 제외하고 나머지 필드는 사용자가 입력하는 데이터에 따라 여러 번 바뀔 수 있습니다. 이번에도 TDD 방식으로 테스트 코드를 먼저 만들어 보겠습니다.
- UserDaoTest
@Test public void update() { dao.deleteAll(); dao.add(user1); //수정할 사용자 dao.add(user2); //수정하지 않을 사용자 user1.setName("jhcode33"); user1.setPassword("password"); user1.setLevel(Level.SILVER); user1.setLogin(1000); user1.setRecommend(999); dao.update(user1); Optional<User> Optuser1Update = dao.get(user1.getId()); if(Optuser1Update != null) { User user1Update = Optuser1Update.get(); checkSameUser(user1, user1Update); } Optional<User> Optuser2Update = dao.get(user2.getId()); if(Optuser2Update != null) { User user2Update = Optuser2Update.get(); checkSameUser(user2, user2Update); } }
user1 정보를 DB에 저장한 후, 기존 user1 객체의 정보를 수정하고 update하여 DB에 담긴 정보까지 수정합니다. UserDaoJdbc에서 get() 메소드가 Optional 타입으로 반환하고 있습니다. 이는 JdbcTemplate에서
queryForStream()
메소드를 사용해서 Stream 타입으로 정보를 가져오기 때문입니다. update가 제대로 되었는지checkSameUser()
메소드를 사용해서 DB에 저장하기 위해서 사용자의 정보를 수정한 user1 객체와 DB에서 가져온 정보로 바인딩한 user1Update 객체를 비교합니다.SQL문에서 where 절을 빼먹을 경우 테스트가 완료되지 않도록 2명의 사용자를 DB에 저장하는 코드로 작성되었습니다.
- UserDao
public interface UserDao { void add(User user); Optional<User> get(String id); List<User> getAll(); void deleteAll(); int getCount(); //User Update Abstract Method void update(User user); }
UserDao 인터페이스에서 사용자를 업데이트 하는 기능을 가진 메소드를 추가합니다. UserDao 인터페이스를 상속 받아 구현한 구체 클래스에서 이를 자세하게 구현하여 사용할 것입니다.
- UserDaoJdbc, update()
//== 사용자 정보 수정 ==// public void update(final User user) { String sql = "UPDATE users SET name=?, " + "password=?, " + "level=?, " + "login=?, " + "recommend=?, " + "WHERE id=?"; this.jdbcTemplate.update(sql, user.getName() , user.getPassword() , user.getLevel().intValue() , user.getLogin() , user.getRecommend() , user.getId()); }
UPDATE users SET name=?, password=?, level=?, login=?, recommend=? WHERE id=?
SQL문은 위와 같습니다. id 컬럼에서 동일한 값을 지닌 레코드를 찾아서 해당 레코드의 컬럼의 값을 변경합니다. jdbcTemplate이 가지고 있는
update()
메소드를 사용했습니다.
✅ UserService, upgradeLevels()
사용자 관리 로직은 어디다가 두는게 좋을까요? UserDaoJdbc는 데이터를 어떻게 가져오고 조작할지를 다루는 곳이므로 비즈니스 로직에 대한 관심사가 없습니다. 사용자 관리 비즈니스 로직은 보통 비즈니스 계층에서 다루게 되며 이를 Service 클래스로 구현합니다.
UserService 클래스는 UserDao 객체를 DI 받아서 사용할 수 있게 구현합니다. 아래는 UserService의 의존관계를 나타냅니다.
- UserService
package com.jhcode.spring.ch5.user.service; import com.jhcode.spring.ch5.user.dao.UserDao; public class UserService { private UserDao userDao; //UserDao 주입 public void setUserDao(UserDao userDao) { this.userDao = userDao; } }
- TestServiceFactory
package com.jhcode.spring.ch5.user.service; import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.datasource.SimpleDriverDataSource; import com.jhcode.spring.ch5.user.dao.UserDaoJdbc; @Configuration public class TestServiceFactory { @Bean public UserDaoJdbc userDao() { UserDaoJdbc userDao = new UserDaoJdbc(); userDao.setDataSource(dataSource()); return userDao; } @Bean public DataSource dataSource() { SimpleDriverDataSource dataSource = new SimpleDriverDataSource(); String url = "jdbc:mariadb://localhost:3306/toby_study?characterEncoding=UTF-8"; String username = "root"; String password = "1234"; dataSource.setDriverClass(org.mariadb.jdbc.Driver.class); dataSource.setUrl(url); dataSource.setUsername(username); dataSource.setPassword(password); return dataSource; } @Bean public UserService userService() { UserService userService = new UserService(); userService.setUserDao(userDao()); return userService; } }
책에서는 applicationContext.xml에서 Bean 객체를 등록하였지만, chap마다 설정이 섞이다 보니 클래스로 따로 관리하는게 좋을 것 같아서 앞에서 배운 클래스로 설정 정보를 등록하는 방법을 사용하였습니다.
@Bean 어노테이션을 사용해서 userService() 메소드를 등록합니다. 해당 메소드는 UserService 객체를 생성하고, UserDao를 주입하고 난 후 userService 객체를 반환하여 빈 객체로 등록됩니다.
🔹비즈니스 로직 구현
사용자의 레벨과 로그인 횟수, 추천수를 보고 레벨 등급을 올리는 비즈니스 로직을 구현해 보겠습니다. 코드는 아래와 같습니다.
- UserService
package com.jhcode.spring.ch5.user.service; import java.util.List; import com.jhcode.spring.ch5.user.dao.UserDao; import com.jhcode.spring.ch5.user.domain.Level; import com.jhcode.spring.ch5.user.domain.User; public class UserService { private UserDao userDao; //UserDao 주입 public void setUserDao(UserDao userDao) { this.userDao = userDao; } //로그인과 추천수에 따라 사용자의 레벨을 업그레이드하는 비즈니스 코드 public void upgradeLevels() { List<User> users = userDao.getAll(); for(User user : users) { //Level이 변화되었는지 체크하는 변수 Boolean changed = null; //BASIC -> SIVER if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) { user.setLevel(Level.SILVER); changed = true; //SIVER -> GOLD } else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) { user.setLevel(Level.GOLD); changed = true; //GOLD = Not Changed } else if (user.getLevel() == Level.GOLD) { changed = false; //Besides.. Not Changed } else { changed = false; } //변화가 있을 경우만 DB에 값 변경 if(changed) { userDao.update(user); } } } }
해당 로직이 잘 수행되는지 테스트 코드를 작성해 보겠습니다.
- UserServiceTest
package com.jhcode.spring.ch5.user.service; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.Arrays; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import com.jhcode.spring.ch5.user.dao.UserDao; import com.jhcode.spring.ch5.user.domain.Level; import com.jhcode.spring.ch5.user.domain.User; @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = {TestServiceFactory.class}) public class UserServiceTest { @Autowired private UserService userService; @Autowired private UserDao userDao; List<User> users; // @Test // public void bean() { // assertNotNull(userService); // } @BeforeEach public void setUp() { //배열을 리스트로 만들어주는 메소드 users = Arrays.asList( new User("user1", "user1", "p1", Level.BASIC, 49, 0), new User("user2", "user2", "p2", Level.BASIC, 50, 0), new User("user3", "user3", "p3", Level.SILVER, 60, 29), new User("user4", "user4", "p4", Level.SILVER, 60, 30), new User("user5", "user5", "p5", Level.GOLD, 100, 100) ); } //User의 Level과 인수로 받은 Level의 값을 비교하는 메소드 private void checkLevel(User user, Level expectedLevel) { Optional<User> optionalUser = userDao.get(user.getId()); if(optionalUser != null) { User userUpdate = optionalUser.get(); assertEquals(userUpdate.getLevel(), expectedLevel); } } @Test public void upgradeLevels() { userDao.deleteAll(); for(User user : users) { userDao.add(user); } //DB의 모든 User을 가지고와서 Level 등급을 조정함 userService.upgradeLevels(); checkLevel(users.get(0), Level.BASIC); //user1 login:49 -> BASIC checkLevel(users.get(1), Level.SILVER); //user2 login:50 -> SILVER checkLevel(users.get(2), Level.SILVER); //user3 login:60, recommend:29 -> SILVER checkLevel(users.get(3), Level.GOLD); //user4 login:60, recommend:30 -> GOLD checkLevel(users.get(4), Level.GOLD); //user5 login:100, recommend:100 -> GOLD } }
- user1 login:49 -> BASIC
- user2 login:50 -> SILVER
- user3 login:60, recommend:29 -> SILVER
- user4 login:60, recommend:30 -> GOLD
- user5 login:100, recommend:100 -> GOLD
userService.upgradeLevels()
메소드를 실행하면 위와 같이 결과가 도출되므로 Test 코드가 정상적으로 작동한다면 비즈니스 로직을 잘 구현한 것이다.
📖 토비 스프링 3.1 -p317~334
🚩jhcode33의 toby-spring-study.git으로 이동하기
Uploaded by N2T