토비의 스프링 정리 프로젝트 #5.1 서비스 추상화 - 사용자 레벨 관리 기능 추가

Jake Seo·2021년 8월 9일
0

토비의 스프링

목록 보기
27/29

서비스 추상화

자바에는 표준 스펙, 상용 제품, 오픈 소스를 통틀어서 사용 방법과 형식은 다르지만 기능과 목적이 유사한 기술이 존재한다. 환경과 상황에 따라 기술이 바뀌고, 그에 따른 API를 사용하고 다른 스타일의 접근 방법을 따라야 한다는 것은 매우 피곤한 일이다.

지금까지 만든 DAO에 트랜잭션을 적용해보며 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있도록 지원하는지 알아보자.

사용자 레벨 관리 기능 추가

현재 UserDao에서는 소위 CRUD라고 말하는 기능만 제공한다. 그 외에 어떠한 비즈니스 로직도 제공하지 않는다. 새로운 요구사항이 들어와서 단지 정보를 넣고 검색하는 것 외에도 정기적으로 사용자의 활동내역을 참고해서 레벨을 조정해주는 기능이 필요하다고 가정해보자.

요구사항

  • 사용자의 LevelBasic, Silver, Gold 중 하나다.
  • 사용자가 처음 가입하면 Basic 레벨이 되며, 이후 활동에 따라 조건부로 한 단계씩 업그레이드 된다.
    • 가입 후 50회 이상 로그인하면 Silver 회원이 된다.
    • Silver 레벨인 상태에서 추천을 30번 이상 받으면 Gold 회원이 된다.
  • 사용자 레벨의 변경 작업은 주기를 가지고 일괄적으로 진행된다. 변경 작업 전에는 조건을 충족해도 레벨의 변경이 일어나지 않는다.

간단한 배치작업을 이용해 수행할 수 있다.

필드 추가

Level Enum 추가

첫 요구사항을 충족하기 위해 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()로 들어올 위험성은 줄였다.

User 필드 추가

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

생성자도 위와 같이 새로 만들어주었다.

UserDaoTest 수정

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

기존의 픽스쳐들에도 LevelloginCount, 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에 새로 생겨난 컬럼이 클래스에 잘 매핑되지 않고 있는 것 같다.

UserDaoJdbc 수정

    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에 잘 반영됐는지 확인한다.

UserDao와 UserDaoJdbc 수정

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절과 같은 부분을 빼먹으면 난감하다. 테스트는 정상적으로 동작하는데 결과는 이상한 경우가 발생할 수 있다.

이러한 문제를 해결하려면 두가지 방법이 있다.

첫번째 방법은 JdbcTemplateupdate()가 돌려주는 반환 값을 확인하는 것이다. JdbcTemplateupdate()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를 추가해주니,

정상적으로 테스트가 작동한다.

UserService.upgradeLevels()

이제 레벨 관리 기능을 추가해야 한다. 레벨 관리 기능은 특정한 시간마다 돌아가며 현재 이용중인 회원 중 레벨업 조건을 만족한 회원의 레벨을 업그레이드 해줄 것이다.

그렇다면 이 사용자 관리 로직은 어디에 두는 것이 좋을까? UserDaoJdbc는 적당하지 않다. DAO는 데이터를 어떻게 가져오고 조작할지 다루는 곳이지 비즈니스 로직을 두는 곳이 아니다. 사용자 관리 비즈니스 로직을 담을 클래스를 하나 추가해주자. 비즈니스 로직 서비스를 제공한다는 의미에서 클래스 이름은 UserService로 한다.

UserServiceUser 도메인과 관련된 비즈니스 로직을 담당하게 되므로, User 객체의 내용과 DB에 있는 User의 내용을 모두 건드려야 한다. UserServiceUserDao 인터페이스 타입으로 userDao 빈을 DI받아서 쓸 것이다. 대문자로 시작하는 UserDao는 인터페이스 이름이고, 소문자로 시작하는 userDao는 빈 이름이니 잘 구분하자.

UserServiceUserDao의 구현 클래스가 변화해도 영향을 받으면 안된다. 데이터 액세스 로직이 바뀌었다고 해도 비즈니스 로직 코드를 수정하는 일이 있어선 안 된다. 따라서 DAO의 인터페이스를 사용하고 DI를 적용하자. DI를 적용하려면 당연히 UserSerivce도 스프링의 빈으로 등록돼야 한다.

아래와 같은 구조로 코드를 작성할 것이다.

UserService 클래스와 빈 등록

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에 주입해주자.

UserServiceTest 클래스

먼저 간단히 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 내부에 있는 userDaonull 인지 간단하게 확인할 수 있다.

upgradeLevels() 메소드

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

어쩌다가 위와 같은 메소드를 만들었다고 생각해보자. 중복된 코드는 좀 나오고 책임의 분리도 잘 안되어있지만 비즈니스 로직이 명확히 보이고 아마 제대로 동작할 것이다.

upgradeLevels() 테스트

모든 케이스를 체크하려면 각 레벨에서 업그레이드 되는 경우와 업그레이드 되지 않는 경우를 나눠서 생각해보면 된다.

레벨은 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)
        );
    }

테스트 픽스쳐는 위와 같이 만들어주었다. loginCount50일 때 업그레이드 기준이고, recommendCount30일 때 업그레이드 기준이어서 일부러 기준의 경계값을 이용한 데이터를 만들었다.

보통 잘못된 현상이 발생할 때는 경계값 근처에서 많이 일어나므로, 경계값으로 테스트하는 습관은 좋은 습관이다. BASICSILVER는 각각 업그레이드가 가능한 경우, 불가능한 경우 모든 경우의 수를 다 만들어주었다.

    @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인 경우 그대로인지를 확인했다.

테스트는 잘 통과한다.

UserService.add()

처음 가입하는 사용자는 기본적으로 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에 이 로직을 넣으면 어떨까? UserDaoadd() 메소드는 사용자 정보를 담은 User 오브젝트를 받아서 DB에 넣어주는 데 충실한 역할을 한다면, UserService에도 add()를 만들어두고 사용자가 등록될 때 적용할만한 비즈니스 로직을 담당하게 하면 될 것이다.

UserDao와 같이 리포지토리 역할을 하는 클래스를 컨트롤러에서 바로 쓰냐 마냐에 대한 논쟁이 있는데, 바로 쓰면 아무런 비즈니스 로직이 들어가지 않은 순수한 CRUD의 의미일 것이다.

먼저 테스트부터 만들어보자. UserServiceadd()를 호출하면 레벨이 BASIC으로 설정되는 것이다. 그런데, UserServiceadd()에 전달되는 User 오브젝트에 Level 값이 미리 설정되어 있다면, 설정된 값을 이용하도록 하자.

그렇다면 테스트 케이스는 두가지 종류가 나올 수 있다.

  • 레벨이 미리 설정된 경우
    • 설정된 레벨을 따른다.
  • 레벨이 미리 설정되지 않은 경우 (레벨이 비어있는 경우)
    • BASIC 레벨을 갖는다.

각각 add() 메소드를 호출하고 결과를 확인하도록 만들자.

가장 간단한 방법은 UserServiceadd() 메소드를 호출할 때 파라미터로 넘긴 User 오브젝트에 level 필드를 확인해보는 것이고, 다른 방법은 UserDaoget() 메소드를 이용해서 DB에 저장된 User 정보를 가져와 확인하는 것이다. 두가지 다 해도 좋고, 후자만 해도 괜찮을 것 같다.

UserServiceUserDao를 통해 DB에 사용자 정보를 저장하기 때문에 이를 확인해보는 게 가장 확실한 방법이다. UserServiceUserDao를 제대로 사용하는지도 함께 검증할 수 있고, 디폴트 레벨 설정 후에 UserDao를 호출하는지도 검증되기 때문이다.

테스트코드 작성 및 UserService의 add 메소드

    @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개에 대해

  • 하나는 levelnull로 설정한 뒤에 userService를 통해 .add()하고 levelBASIC인지 확인한다.
  • 다른 하나는 levelLevel.SILVER인 상태 그대로 userService를 통해 .add()하여 level이 그대로 SILVER인지 확인한다.

테스트를 돌려보면 성공이고, 다만 테스트가 조금 복잡한 것이 흠이다. 간단한 비즈니스 로직을 담은 코드를 테스트하기 위해 DAO와 DB까지 모두 동원되는 점이 조금 불편하다. 이런 테스트는 깔끔하고 간단하게 만드는 방법이 있는데 뒤에서 다시 다뤄보자.

코드 개선

어느정도 요구사항은 맞춰놨지만, 아직 코드가 깔끔하지 않게 느껴진다. 다음 사항들을 체크해보자.

  • 코드에 중복된 부분은 없는가?
  • 코드가 무엇을 하는 것인지 이해하기 불편하진 않은가?
  • 코드가 자신이 있어야 할 자리에 있는가
  • 앞으로 변경이 일어날 수 있는 건 어떤 것이며, 그 변화에 쉽게 대응할 수 있게 작성 되었는가?

upgradeLevels() 메소드 코드의 문제점

    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);의 작업이 필요함을 알려주는 역할이다.

잘 살펴보면 관련이 있지만, 사실 성격이 조금 다른 것들이 섞여있거나 분리돼서 나타나는 구조다.

변경될만한 것 추측하기

  • 사용자 레벨
  • 업그레이드 조건
  • 업그레이드 작업
  • 사용자 레벨이 변경되면?
    • 현재 if 조건 블록이 레벨 개수만큼 반복되고 있다. 새로운 레벨이 추가되면, Level ENUM도 수정해야 하고, upgradeLevels()의 레벨 업그레이드 로직을 담은 코드에 if 조건식과 블록을 추가해줘야 한다.
  • 업그레이드 작업이 변경되면?
    • 추후에 레벨을 업그레이드 작업에서 이를테면 레벨 업그레이드 축하 알람 등 새로운 작업이 추가되면, user.setLevel(다음레벨); 뒤에 추가적인 코드를 작성해주어야 할 것이다. 그러면 점점 메소드의 if문 블록은 커진다.
  • 업그레이드 조건이 변경되면?
    • 업그레이드 조건도 문제다. 새로운 레벨이 추가되면 기존 if조건과 맞지 않으니 else로 이동하는데, 성격이 다른 두 가지 경우가 모두 한 곳에서 처리되는 것은 뭔가 이상하다.
    • 업그레이드 조건이 계속 까다로워지면 마지막엔 if() 내부에 들어갈 내용이 방대하게 커질 수 있다.

아마 upgradeLevels() 코드 자체가 너무 많은 책임을 떠안고 있어서인지 전반적으로 변화가 일어날수록 코드가 지저분해진다는 것을 추측할 수 있다. 지저분할수록 찾기 힘든 버그가 숨어들어갈 확률이 높아질 것이다.

upgradeLevels() 리팩토링

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

        for (User user : users) {
            if(canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }
    }

위는 upgradeLevels()에서 기본 작업 흐름만 남겨둔 코드이다. 이 코드는 한 눈에 읽기에도 사용자 정보를 받아서 레벨 업그레이드를 할 수 있으면 레벨 업그레이드를 한다. 명확하다.

이는 구체적인 구현에서 외부에 노출할 인터페이스를 분리하는 것과 마찬가지 작업을 코드에 한 것이다.

이제 인터페이스화된 메소드들을 하나씩 구현해보자.

canUpgradeLevel()

    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이 발생하기 때문에 해당 등급에 대한 로직 처리를 하지 않았음을 쉽게 알 수 있다.

upgradeLevel()

    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에게 위임하지 말자.

Level enum

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

UserServicecanUpgradeLevel() 메소드에서 업그레이드 가능 여부를 미리 판단해주긴 하지만, User 오브젝트를 UserService만 사용한다는 보장은 없으므로, 스스로 예외상황에 대한 검증 기능을 갖고 있는 편이 안전하다.

Level enum은 다음 레벨이 없는 경우에는 nextLevel()에서 null을 반환한다. 따라서 이 경우에는 User의 레벨 업그레이드 작업이 진행돼서는 안되므로, 예외를 던져야 한다.

애플리케이션의 로직을 바르게 작성하면 이런 경우는 아예 일어나지 않겠지만, User 오브젝트를 잘못 사용하는 코드가 있다면 확인해줄 수 있으니 유용하다.

User에 업그레이드 작업을 담당하는 독립적인 메소드를 두고 사용할 경우, 업그레이드 시 기타 정보도 변경이 필요해졌을 때, 그 장점이 무엇인지 알 수 있을 것이다. 이를테면 마지막으로 업그레이드 된 시점을 기록하고 싶다면, lastUpgraded 필드를 추가하고 this.lastUpgraded = new Date();와 같은 코드를 추가함으로써, 쉽게 동작을 더할 수 있다.

UserService.upgradeLevel()

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

User 객체에 레벨을 업그레이드 하는 책임을 주어 코드가 한결 깔끔해졌다. 이전의 if문이 많이 들어있던 코드를 생각하면, 간결하고 작업 내용이 명확하게 드러난다. 각 오브젝트가 해야 할 책임도 깔끔하게 분리됐다.

지금 개선한 코드를 전체적으로 살펴보면, 각 오브젝트와 메소드가 각각 자기 몫의 책임을 맡아 일을 하는 구조로 만들어졌음을 알 수 있을 것이다.

UserService, User, Level이 내부 정보를 다루는 자신의 책임에 충실한 기능을 갖고 있으면서 필요가 생기면 이런 작업을 수행해달라고 서로 요청하는 구조이다.

각자 자기 책임에 충실한 작업만 하고 있으니 코드를 이해하기도 쉽다. 또, 변경이 필요할 때 어디를 수정해야 할지도 쉽게 알 수 있다. 잘못된 요청이나 작업을 시도했을 때 이를 확인하고 예외를 던져줄 준비도 다 되어 있다.

각각을 독립적으로 테스트하도록 만들면 테스트 코드도 단순해진다.

각 클래스가 자신이 갖는 책임에 대한 코드만 작성하도록 노력했다.

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

처음 구현했던 UserServiceupgradeLevels() 메소드는 User 오브젝트에서 데이터를 가져와서 그것을 가지고 User 오브젝트나 Level enum이 해야 할 작업을 대신 수행하고 직접 User 오브젝트의 데이터를 변경해버렸다. 이보다는 UserServiceUser에게 레벨 업그레이드 작업을 해달라고 요청하고, 또 UserLevel에게 다음 레벨이 무엇인지 알려달라고 요청하는 방식으로 동작하게 하는 것이 바람직하다.

만일,

  • BRONZE 레벨을 BASICSILVER 사이에 추가하라.
  • BRONZE에서 SILVER로 업그레이드하는 조건은 로그인 횟수 80번이다.

보자마자 Level enum에 있는 다음 레벨과 관련된 코드와 UserService에 있는 canUpgradeLevel() 메소드를 떠올릴 수 있다면 성공적일 것 같다.

  • 가장 최근의 레벨 변경 날짜를 저장해두어라.
  • 레벨 변경 시 콘솔에 로그를 남기도록 하라.

위와 같은 요구사항이 들어오면 먼저 레벨 변경은 UserupgradeLevel() 메소드에서 수행하는 것이니 User의 필드에 최근 레벨 변경 날짜를 추가하고 lastLevelUpdated = new LocalDateTime() 등의 코드를 추가하는 것으로 해결할 수 있을 것이다.

로그를 남기는 것은 User의 상태와는 전혀 관련이 없으니 UserServiceupgradeLevel()에서 DB 업데이트 이후에 것이 바람직할 것이다.

이렇게 책임에 맞게 코드를 작성하면 변경 후에도 코드는 여전히 깔끔하고 코드를 이해하는데도 어려움이 없을 것이다.

물론 지금까지 진행한 UserService의 리팩토링과 그 결과로 만들어진 코드가 정답이라거나 완벽한 것은 아니다. 애플리케이션의 특성과 구조, 발전 방향 등에 따라 더 세련된 설계도 가능하다. 좀 더 객체지향 적인 특징이 두드러지게 구조를 바꿀 수도 있다. 현재 UserService의 코드는 5장에서 설명하려는 스프링의 기능을 적용하기 적절한 구조로 만들어둔 것 뿐이다.

항상 코드를 더 깔끔하고 유연하면서 변화에 대응하기 쉽고 테스트하기에 좋게 만드려고 노력해야 함을 기억하고 다음으로 넘어가자.

User 테스트

방금 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() 메소드에 좀 더 복잡한 기능이 추가됐을 때도 이 테스트를 확장해 사용할 수 있다.

UserServiceTest 개선

기존에는 다음 레벨에 대한 정보를 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번의 카운트를 보너스로 줬기 때문에 실패해야 정상이다.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글