기존에 작성한 UserDao는 단순히 JDBC를 사용하여 데이터를 읽고 쓰는 클래스였다.
이것을 이용해 사용자를 관리하는 서비스를 간단하게 만들어보자.
만들 기능들은 다음과 같다.
- 사용자의 레벨은 BRONZE, SILVER, GOLD 3가지이다.
- 처음 가입하면 BRONZE 레벨이다.
- 가입 후 로그인을 50회 이상 하면 BRONZE -> SILVER로 승급한다.
- SILVER 레벨에서 30번 이상 좋아요를 받으면 GOLD로 승급한다.
- 승급은 즉시 일어나는게 아니라, 배치로 일괄적으로 진행된다.
필드 추가
일단 레벨을 나타낼 enum을 만들자.
이런 값들을 나타내는 가장 편한 방법은 역시 enum일 것이다.
package seungkyu;
import lombok.Getter;
@Getter
public enum Level {
BRONZE(1),
SILVER(2),
GOLD(3);
private final int value;
Level(int value) {
this.value = value;
}
public static Level valueOf(int value) {
return switch (value) {
case 1 -> BRONZE;
case 2 -> SILVER;
case 3 -> GOLD;
default -> throw new IllegalArgumentException("Invalid level value: " + value);
};
}
}
이렇게 int를 value로 가지는 enum을 만들어준다.
그리고 이런 필드를 가질 수 있도록 User 클래스도 수정해준다.
@Builder
@Getter
@Setter
@EqualsAndHashCode
public class User {
private Level level;
private Integer login;
private Integer recommend;
private String id;
private String name;
private String password;
}
데이터베이스에도 column들은 추가해둔다.
@Test
public void addAndGet(){
Assertions.assertEquals(0, userDao.getCount());
User createdUser = User.builder()
.id("seungkyu")
.name("승규")
.password("password")
.level(Level.BRONZE)
.recommend(1)
.login(10)
.build();
userDao.add(createdUser);
Assertions.assertEquals(1, userDao.getCount());
User readUser = userDao.get("seungkyu");
Assertions.assertEquals(createdUser, readUser);
}
그리고는 이렇게 테스트를 수행해보면 통과하지 못한다.
왜냐하면 데이터메이스에는 level, login, recommend 값을 저장하고 조회하지 않기 때문이다.
일단 나머지 테스트 코드도 해당 필드를 가지도록 모두 수정해준다.
그렇기에 UserDao도 같이 수정해준다.
그냥 단순히 Mapper를 수정해주고
private final RowMapper<User> userRowMapper = (rs, rowNum) -> User.builder()
.id(rs.getString("id"))
.name(rs.getString("name"))
.password(rs.getString("password"))
.login(rs.getInt("login"))
.level(Level.valueOf(rs.getInt("level")))
.recommend(rs.getInt("recommend"))
.build();
insert문도 추가해서 저장해준다.
public void add(User user){
this.jdbcTemplate.update(
"insert into users(id, name, password, level, login, recommend) values(?, ?, ?, ?, ?, ?)",
user.getId(),
user.getName(),
user.getPassword(),
user.getLevel().getValue(),
user.getLogin(),
user.getRecommend()
);
}
mapper도 저렇게 분리하지 않았었다면, 아마 한 번에 수정하지 못해 힘들었을 것이다.
또한, 데이터베이스에는 enum을 저장하지 못하기에 level에 해당하는 값은 int로 바꾸어 저장해준다.
그러고 테스트를 돌려보면 통과하는 것을 볼 수 있다.
사용자 수정 기능 추가
사용자의 정보는 ID를 제외하고 언제든지 수정이 가능하다.
TDD의 느낌으로 일단 수정 관련 테스트 먼저 만들고 시작한다.
@Test
public void update(){
userDao.deleteAll();
var originalName = UUID.randomUUID().toString().substring(0, 6);
var newName = UUID.randomUUID().toString().substring(0, 6);
var originalLevel = Level.BRONZE;
var newLevel = Level.GOLD;
User modifyUser = User.builder()
.id("seungkyu")
.name(originalName)
.password("password")
.level(originalLevel)
.recommend(1)
.login(1)
.build();
User unmodifyUser = User.builder()
.id("seungkyu1")
.name(originalName)
.password("password")
.level(originalLevel)
.recommend(1)
.login(1)
.build();
userDao.add(modifyUser);
userDao.add(unmodifyUser);
modifyUser.setName(newName);
modifyUser.setLevel(newLevel);
userDao.update(modifyUser);
Assertions.assertEquals(modifyUser, userDao.get("seungkyu"));
Assertions.assertEquals(unmodifyUser, userDao.get("seungkyu1"));
}
다른 사용자들은 변경되면 안되기 때문에 이렇게 테스트를 작성했다.
이렇게 일단 만들고 update 메서드를 만들러 가보자.
public void update(User user) {
this.jdbcTemplate.update(
"update users set name = ?, password = ?, level = ?, login = ?, recommend = ? " +
"where id = ?",
user.getName(),
user.getPassword(),
user.getLevel().getValue(),
user.getLogin(),
user.getRecommend(),
user.getId()
);
}
MySQL의 간단한 쿼리이기에 어렵지는 않을 것이다.
UserService.upgradeLevels()
데이터베이스에 접근하는 부분은 Dao로 작성하고 있다.
만약 레벨을 올리는 로직이 필요하다면, UserDao에 레벨만 올리는 코드를 작성해야 할까?
컬럼 하나만을 바꾸기 위해 쿼리를 또 작성해야 할 것이다.
그렇게 개발하기보다 사용자의 관리 로직을 따로 빼는 것이 더 적당해보인다.
비즈니스 로직 서비스를 제공한다는 의미로 UserService로 파일을 만든다.
UserService는 UserDao 인터페이스 타입으로 UserDao 빈을 DI 받아 사용하게 만든다.
또한 개발자답게 UserService에 대한 테스트도 작성해야 한다.
그렇기에 의존관계를 정리해보면 다음과 같을 것이다.
늘 그렇듯 일단 테스트코드부터 만들어준다.
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"/test-applicationContext.xml"})
public class UserServiceTest {
private UserService userService;
List<User> users;
@Autowired
private ApplicationContext applicationContext;
@Autowired
private UserDao userDao;
private String uuidHelper(){
return UUID.randomUUID().toString().substring(0, 6);
}
@BeforeEach
public void setUp() {
this.userService = applicationContext.getBean("userService", UserService.class);
this.userDao = applicationContext.getBean("userDao", UserDao.class);
users = Arrays.asList(
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.BRONZE).login(49).recommend(0).build(),
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.BRONZE).login(50).recommend(1).build(),
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(29).build(),
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.SILVER).login(60).recommend(30).build(),
User.builder().id(uuidHelper()).name(uuidHelper()).password("p1").level(Level.GOLD).login(100).recommend(100).build()
);
}
@Test
public void upgradeLevels() {
userDao.deleteAll();
for(User user : users) userDao.add(user);
userService.upgradeLevels();
Assertions.assertEquals(Level.BRONZE, userDao.get(users.get(0).getId()).getLevel());
Assertions.assertEquals(Level.SILVER, userDao.get(users.get(1).getId()).getLevel());
Assertions.assertEquals(Level.SILVER, userDao.get(users.get(2).getId()).getLevel());
Assertions.assertEquals(Level.GOLD, userDao.get(users.get(3).getId()).getLevel());
Assertions.assertEquals(Level.GOLD, userDao.get(users.get(4).getId()).getLevel());
}
}
UserDao와 UserService를 주입해주고, 각 유저들을 만든 뒤 upgradeLevels를 호출해 업데이트가 되는지 확인한다.
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{
private final UserDao userDao;
public void upgradeLevels() {
List<User> users = userDao.getAll();
for(User user : users)
{
Boolean changed = null;
if(user.getLevel() == Level.BRONZE && user.getLogin() >= 50)
{
user.setLevel(Level.SILVER);
changed = true;
}
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);
}
}
}
그리고 이렇게 레벨을 변경하는 service를 만든다.
이렇게 레벨만을 올리기 위해 쿼리를 작성하기는 힘들고, 해당 쿼리로 조건을 체크해서 레벨을 올려야 한다.
이런 방법 보다는 service layer에서 dao를 통해 조회한 데이터를 확인해 다시 dao로 저장하는 이런 비즈니스 로직을 분리하는 것이 더 좋다는 판단이 들었다.
UserService.add()
사용자를 처음 생성한다면 BRONZE 레벨로 만들어주어야 한다.
처음 생각으로는 UserDao에 add 부분을 수정해서 기본적으로 BRONZE 레벨을 줄 수 있지만, UserDao는 데이터베이스에만 관심을 가지고 비즈니스 로직은 위와같이 분리하는 것이 맞다고 생각이 들었다.
그렇기에 비즈니스 로직을 담당하는 UserService에서 해결해보자.
늘 그렇듯 테스트먼저 작성한다.
@Test
public void add(){
userDao.deleteAll();
User userWithLevel = users.get(4);
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());
Assertions.assertEquals(userWithLevel.getLevel(), userWithLevelRead.getLevel());
Assertions.assertEquals(Level.BRONZE, userWithoutLevelRead.getLevel());
}
만약 처음 등록되는 유저로 level이 Null이라면 Bronze가 들어가있어야 한다.
그렇기에 userService 단계에서 level을 확인하고 null이면 초기값을 설정해준다.
public void add(User user) {
if(user.getLevel() == null)
user.setLevel(Level.BRONZE);
userDao.add(user);
}
간단한 로직이다.
테스트도 간단히 통과하는 것을 볼 수 있다.
코드 개선
코드를 작성하고 나서는 항상 스스로에게 더 나은 코드를 만들 수 있는지 질문을 해보면 좋다고 한다.
public void upgradeLevels() {
List<User> users = userDao.getAll();
for(User user : users)
{
Boolean changed = null;
if(user.getLevel() == Level.BRONZE && user.getLogin() >= 50)
{
user.setLevel(Level.SILVER);
changed = true;
}
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);
}
}
현재 UserService의 upgradeLevels()이다.
보면 알겠지만, 굉장히 읽기 불편하다.
개선이 필요하다.
우선 코드 자체가 업그레이드가 가능한지 묻는 조건문과 사용자를 업데이트하는 로직이 섞여있다.
이 로직을 업그레이드가 가능한지 확인하는 로직과 업그레이드를 하는 로직으로 분리해보자.
private boolean isUpgradeLevel(User user) {
Level level = user.getLevel();
return switch (level) {
case BRONZE -> user.getLogin() >= 50;
case SILVER -> user.getRecommend() >= 30;
case GOLD -> false;
};
}
이렇게 업그레이드가 가능한지 확인하는 isUpgradeLevel()
private void upgradeLevel(User user) {
user.setLevel(Level.from(user.getLevel().getValue() + 1));
userDao.update(user);
}
직접 레벨을 올려주는 upgradeLevel()이다.
그에따라 upgradeLevels()도 이렇게 수정이 가능하다.
public void upgradeLevels() {
for(User user : userDao.getAll())
{
if(isUpgradeLevel(user))
upgradeLevel(user);
}
}
코드가 간결해지기도 했지만, 두개의 관심사(확인, 업그레이드)가 분리되어 각각의 메서드에서 처리하고 있는 것이 더 장점이다.
그런 관점에서 보면 다시 upgradeLevel()을 확인해보자.
private void upgradeLevel(User user) {
user.setLevel(Level.from(user.getLevel().getValue() + 1));
userDao.update(user);
}
사용자의 레벨을 올리는 메서드는 여기서 할 작업은 아닌 거 같다.
그렇기에 nextLevel로 설정하는 메서드는 User로 넘기도록 한다.
@Builder
@Getter
@Setter
@EqualsAndHashCode
public class User {
private Level level;
private Integer login;
private Integer recommend;
private String id;
private String name;
private String password;
public void nextLevel(){
this.setLevel(Level.from(this.getLevel().getValue() + 1));
}
}
private void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
그에따라 또 분리할 수 있게 되었다.
만약 각 단계마다 추가적으로 해야할 작업이 생긴다면 그 메서드에 추가하면 되기에 관심사에 맞게 작업 할 수 있다.
이렇게 객체지향적인 코드는 다른 오브젝트의 데이트를 직접 수정하는 것이 아니라, 해당 오브젝트에게 작업을 요청한다고 한다.
그러면 이제 진짜 마지막으로 또 수정할 부분이 보인다.
private boolean isUpgradeLevel(User user) {
Level level = user.getLevel();
return switch (level) {
case BRONZE -> user.getLogin() >= 50;
case SILVER -> user.getRecommend() >= 30;
case GOLD -> false;
};
}
이 부분도 사실 UserService가 아닌, User에 있어야 하는 코드인 것이다.
public void upgradeLevels() {
for(User user : userDao.getAll())
{
if(user.isUpgradeLevel())
upgradeLevel(user);
}
}
해당 메서드를 User로 옮기고 이렇게 수정해줬다.
User의 테스트도 만들어보자.
테스트는 어차피 꾸준하게 사용하니, 간단하더라도 추가해두는 것이 좋다.
class UserTest {
private User user;
@BeforeEach
void setUp() {
user = User.builder()
.id(UUID.randomUUID().toString().substring(0, 8))
.name("seungkyu")
.password("123456")
.login(1)
.recommend(2)
.level(Level.BRONZE)
.build();
}
@Test
public void upgradeLevelTest(){
user.upgradeLevel();
Assertions.assertEquals(Level.SILVER, user.getLevel());
}
}
User는 스프링 빈이 아니기 때문에 SpringExtension을 추가해주지 않아도 괜찮다.
또한 User로 다시 돌아가보자.
public boolean isUpgradeLevel() {
Level level = this.level;
return switch (level) {
case BRONZE -> this.login >= 50;
case SILVER -> this.recommend >= 30;
case GOLD -> false;
};
}
지금은 조건을 이렇게 50, 30과 같은 수로 나타내고 있다.
하지만 나중에 변경될 수도 있으며, 이 조건을 외부에서 사용할 경우도 있을 것이다.
그렇기에 이렇게 숫자로 나타내는 것이 아닌 상수로 만들어주는 것이 좋다.
해당 조건의 값
public class UserServiceImpl implements UserService{
public static final int SILVER_LOGIN_COUNT = 50;
public static final int GOLD_RECOMMEND_COUNT = 30;
}
은 비즈니스 로직에 있는것이 맞다고 생각하여 UserService에 상수로 선언하였다.
그리고 User 클래스에서 값들에 맞추어 코드를 변경해준다.
public boolean isUpgradeLevel() {
Level level = this.level;
return switch (level) {
case BRONZE -> this.login >= SILVER_LOGIN_COUNT;
case SILVER -> this.recommend >= GOLD_RECOMMEND_COUNT;
case GOLD -> false;
};
}
이번 글에서는 비즈니스 로직에 맞추어 service layer를 추가했다.
그리고 관심사에 맞추어 최대한 로직을 분리함으로 객체지향에 맞추어 수정했다.
유지보수 단계로 넘어가게 된다면, 객체지향적으로 설계하는 것이 굉장히 큰 장점이 되었다.
앞으로도 관심사에 맞추어 최대한 객체지향적으로 개발을 해보도록 하자.
'백엔드 > 토비의 스프링' 카테고리의 다른 글
[토비의 스프링] 5.3 서비스 추상화와 단일 책임 원칙 (0) | 2025.06.09 |
---|---|
[토비의 스프링] 5.2 트랜잭션 서비스 추상화 (2) | 2025.06.09 |
[토비의 스프링] 4.2 예외 전환 (1) | 2025.06.04 |
[토비의 스프링] 4.1 사라진 SQLException (1) | 2025.06.03 |
[토비의 스프링] 3.6 스프링의 JdbcTemplate (1) | 2025.06.02 |