반응형

1. 개요

 현재 트랜잭션 기술과, UserService, UserDao같은 어플리케이션 로직에 대해 추상화기법이 적용되어 있다. UserDao와 UserService는 각각 User에 대한 데이터 처리, 비지니스 처리에 대한 관심으로 구분되어 있다.

 이 둘 모두 전략 패턴이 적용되어 있으며, 낮은 결합도를 갖는다. 결합도가 낮기 때문에 UserDao의 데이터 처리 로직이 바뀌어도 UserService의 코드에는 영향을 주지 않는다. 반대도 마찬가지다.

 

 UserDao는 DB Connection을 생성하는 방법에 대해서도 독립적이다. DataSource 인터페이스와 DI를 통해 추상화된 방식으로 로우레벨의 DB 연결 기술을 사용하기 때문이다. DB 풀링 라이브러리를 사용하든, JDBC의 DriverManager를 사용하든, WAS가 JNDI를 통해 데이터 소스 서비스를 이용하든 상관없이 UserDao 코드에는 영향을 주지 않는다. 즉, UserDao와 DB 연결 기술도 결합도가 낮고, 수직적으로 분리되어 있다는 뜻이다.

 

 이렇게 결합도가 낮은 구조로 만들 수 있는 데에는 스프링의 DI가 중요한 역할을 하고 있다. DI의 가치는 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다. 이제 이러한 내용을 단일 책임원칙이라는 개념과 함께 이해해보자.

 


2. 단일 책임 원칙

 

2.1. 단일 책임 원칙이란?

 DI의 가치인 '분리'는 객체지향 설계의 원칙 중 하나인 '단일 책임 원칙'으로 설명할 수 있다.

단일 책임 원칙(SRP : Single Responsibility Principle)
 객체는 단 하나의 책임만 가져야 한다.

 

 UserService에 JDBC Connection 메서드를 직접 사용했을 때에는 사용자의 레벨 관리와 트랜잭션 관리에 대한 두가지 책임을 갖고 있었다. 단일 책임 원칙을 지키지 못하는 것이다. 이로써 사용자의 레벨 관리 정책이 바뀌거나, 트랜잭션 기술이 JDBC 에서 JTA로 변경될 경우 UserService 클래스의 코드도 반드시 수정되어야 했었다. 

 추후 트랜잭션 기술에 대한 추상화 기법의 도입하여 트랜잭션 관리 책임을 트랜잭션 매니저에게 위임했다. 이로써 UserService는 단일 책임 원칙을 지키게 됐고, 트랜잭션 기술이 바뀌어도 UserService의 코드는 바뀔 필요가 없게 되었다.

 

2.2. 단일 책임 원칙의 장점

 어떤 변경이 필요할 때 수정 대상이 명확해진다. 트랜잭션 기술이 바뀌면 기술 추상화 계층의 설정만 바꿔주면 되고, 데이터를 가져오는 테이블이 바뀌었다면 데이터 액세스 로직을 담고있는 UserDao만 변경하면 된다. 마찬가지로 레벨 관리 정책이 바뀌면 UserService만 변경하면 된다.

 이러한 구조로 만들기 위해 빠지지 않았던게 바로 스프링 DI였다. 인터페이스의 도입과 적절한 DI는 단일 책임 원칙 뿐 아니라 개방 폐쇄 원칙도 잘 지키게 되니 결합도가 낮아 변경에 유연하며, 단일 책임에 집중하는 응집도 높은 코드를 개발할 수 있다.

 


3. 메일 서비스 추상화

 위 내용을 생각하며 메일 발송에 대한 추상화를 적용해보자. 메일은 자바에서 제공하는 메일 발송 표준 기술인 JavaMail을 사용한다. 일반적으로 사용되는 예제 코드를 사용했으며, 필자의 경우 NAVER에서 제공하는 SMTP를 사용하였기에 사용에 필요한 여러 프로퍼티와 id, password 정보를 담고있는 Authenticator 객체 활용해주었다.

 

3.1. DefaultUserLevelUpgradePolicy.java

public class DefaultUserLevelUpgradePolicy implements UserLevelUpgradePolicy{

    ...

    public String upgradeLevel(User user){
        user.upgradeLevel();
        userDao.update(user);
        sendUpgradeMail(user); // 메일 발송 추상화메서드
        return user.getId();
    }

    private void sendUpgradeMail(User user) {
        Properties props = new Properties();
        props.put("mail.host", "smtp.naver.com");
        props.put("mail.port", "465");
        props.put("mail.smtp.auth" , "true");
        props.put("mail.smtp.ssl.enable", "true");
        props.put("mail.smtp.ssl.trust", "smtp.naver.com");
        props.put("mail.debug", "true");

        Session s = Session.getInstance(props, new Authenticator() {
            protected PasswordAuthentication getPasswordAuthentication() {
                return new PasswordAuthentication("ID","PASSWORD");
            }
        });

        MimeMessage message = new MimeMessage(s);

        try{
            message.setFrom(new InternetAddress("test@naver.com"));
            message.addRecipient(Message.RecipientType.TO, new InternetAddress(user.getEmail()));
            message.setSubject("Upgrade 안내");
            message.setText("사용자님의 등급이 " + user.getLevel().name() +" 로 업그레이드 되었습니다.");
            Transport.send(message);
        } catch (AddressException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        } catch (MessagingException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }

    }
}

 

3.2. 테스트 결과

 기존에 작성했던 테스트 코드를 실행하니 레벨 업그레이드 관련된 테스트 시 지정한 메일 주소로 메일이 발송됨을 확인할 수 있었다. (필자의 메일로 보냈다.)

메일 발송 테스트

 

 

3.3. 메일이 발송되는 테스트

 이제 테스트 코드를 실행할 때마다 메일이 발송되게 되었다. 그런데 이처럼 메일이 발송되는 테스트는 바람직한 테스트라고 할 수 없다. 그 이유는 다음과 같다.

 

 첫째, 메일 발송이란 부하가 큰 작업이다. 필자의 경우 네이버에서 제공하는 메일 서버를 사용했지만, 만약  유료 메일서버나, 본인이 속한 회사에서 실제 운영중인 메일 서버를 사용한다면 해당 서버에 상당한 부담을 줄 수 있다.

 둘째, 실수로 인해 메일을 발송할 수 있다. 메일 테스트를 위해 개발자 자신의 이메일이나, 더미 이메일을 설정할수도 있지만, 개발, 운영 환경 설정 실수로 인해 테스트 메일을 실 사용자에게 발송할 수도 있다.

 셋째, 테스트 속도가 느려진다. 레벨 업그레이드에 대한 테스트 케이스가 많다면, 실제 메일 발송도 많이 일어나게 된다. 메일 발송이 많다면, 테스트도 느려질 수 있다.

 

 메일 서버는 충분히 테스트된 시스템이므로 초기에 몇번정도만 실제 주소로 메일을 보내고 받는걸 확인하는 걸로 충분하다. 결국 바람직한 메일 발송 테스트란 메일 전송 요청은 받되 메일 발송은 되지 않도록 하는것이다. 운영 시에는 JavaMail을 직접 이용해서 동작하도록 하고, 테스트 중에는 JavaMail을 사용할 때와 동일한 인터페이스를 갖는 코드가 동작하도록 구현해보자.

 


4. 메일 발송 테스트를 위한 서비스 추상화

 실제 메일 전송을 수행하는 JavaMail 대신 JavaMail과 같은 인터페이스를 갖는 오브젝트를 만들어 사용하려했으나 그럴 수 없었다. 이유는 JavaMail의 핵심 API에는 DataSource처럼 인터페이스로 만들어진게 없기때문에 구현부를 바꿀 수 없다. 메일 발송 시 사용하는 Session, MailMessage, Transport 모두 인터페이스가 아닌 클래스이다. 실제로 JavaMail은 확장이나 지원이 불가능하도록 만들어진 API 중 하나라고 한다.

 

 스프링에서는 이러한 JavaMail에 대한 추상화 기능을 제공하고 있다. JavaMail의 서비스 추상화 인터페이스는 다음과 같다.

public interface MailSender{
    void send(SimpleMailMessage simpleMessage) throws MailException;
    void send(SimpleMailMessage[] mailMessages) throws MailException;
}

 

 이 인터페이스는 SimpleMailMessage라는 인터페이스를 구현한 클래스에 담긴 메일 메시지를 전송하는 메서드로 구성되어 있다. 이를 사용해 메일 발송 코드를 수정해보자.

 

4.1. 코드 리팩토링

4.1.1. DefaultUserLevelUpgradePolicy.java

public class DefaultUserLevelUpgradePolicy implements UserLevelUpgradePolicy{

    private IUserDao userDao;

    private MailSender mailSender;

    private static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

    private static final int MIN_RECOMMEND_FOR_GOLD = 30;

    public void setUserDao(IUserDao userDao){
        this.userDao = userDao;
    }
    public void setMailSender(MailSender mailSender){
        this.mailSender = mailSender;
    }

    ...
    
    private void sendUpgradeMail(User user) {

        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setTo(user.getEmail());
        mailMessage.setFrom("test@naver.com");
        mailMessage.setSubject("Upgrade 안내");
        mailMessage.setText("사용자님의 등급이 " + user.getLevel().name() +" 로 업그레이드 되었습니다.");

        mailSender.send(mailMessage);
    }
}

 비지니스 로직을 서비스 추상화 인터페이스인 MailSender에 맞게 수정한다. SMTP 및 계정 관련 속성은 DI 시 설정하고, SimpleMailMessage 클래스에는 발송할 메일과 관련된 내용을 설정한다.

 

4.1.2. applicationContext.xml

<?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="driverClass" value="com.mysql.jdbc.Driver"></property>
        <property name="url" value = "jdbc:mysql://localhost/spring"></property>
        <property name="username" value = "ID"></property>
        <property name="password" value = "PW"></property>
    </bean>

    <bean id = "userDao" class = "org.example.user.dao.UserDaoJdbc">
        <property name = "dataSource" ref = "dataSource"></property>
    </bean>

    <bean id = "userService" class = "org.example.user.service.UserService">
        <property name="userDao" ref = "userDao"></property>
        <property name="userLevelUpgradePolicy" ref = "userLevelUpgradePolicy"></property>
        <property name="transactionManager" ref="transactionManager"></property>
    </bean>

    <bean id = "userLevelUpgradePolicy" class = "org.example.user.attribute.DefaultUserLevelUpgradePolicy">
        <property name="userDao" ref = "userDao"/>
        <property name="mailSender" ref ="mailSender"></property>
    </bean>

    <bean id = "transactionManager" class ="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource"></property>
    </bean>

    <bean id = "mailSender" class = "org.springframework.mail.javamail.JavaMailSenderImpl">
        <property name="host" value = "smtp.naver.com"></property>
        <property name="port" value = "465"></property>
        <property name="username" value = "ID"></property>
        <property name="password" value = "PASSWORD"></property>
        <property name="javaMailProperties">
            <props>
                <prop key="mail.smtp.ssl.enable">"true"</prop>
                <prop key="mail.smtp.ssl.trust">"smtp.naver.com"</prop>
                <prop key="mail.smtp.auth">"true"</prop>
            </props>
        </property>
    </bean>
</beans>

 JavaMailSenderImpl에 대한 Bean을 생성하고, userLevelUpgradePolicy의 mailSender에 DI한다.

 

4.2. 테스트

 아래와 같이 JavaMail API를 사용했을 때와 동일하게 메일이 발송된다면 추상화 인터페이스로의 전환에 성공한것이다.

 

4.3. 테스트용 메일 발송 오브젝트 생성

 메일 전송 기능을 추상화해서 인터페이스를 적용하고 DI를 통해 빈으로 분리했다. 이제 MailSender 인터페이스를 구현하는 테스트 클래스를 만들고, 테스트용 application-context에서 DI 해주면 된다.

 

4.3.1. DummyMailSender.java

public class DummyMailSender implements MailSender{
    @Override
    public void send(SimpleMailMessage simpleMessage) throws MailException {

    }

    @Override
    public void send(SimpleMailMessage... simpleMessages) throws MailException {

    }
}

 MailSender 인터페이스를 구현하였으나, 로직은 아무것도 작성하지 않았다. 테스트에서는 메일 발송할 필요가 없기때문이다.

 

 참고로 JavaMailSenderImpl 클래스의 경우 아래와 같이 실제 메일 발송 로직이 구현되어 있는 상태이다. 이제 운영시에는 이 클래스를 DI, 테스트 시에는 DummyMailSender 클래스를 DI하면 된다.

 

JavaMailSenderImpl.java 코드의 인터페이스 구현부

 

 

4.3.2. test-applicationContext.xml

<?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 = "mailSender" class = "org.example.user.service.DummyMailSender"/>
</beans>

 test-applicationContext.xml의 mailSender의 구현체 클래스를 DummyMailSender로 설정한다.

 

 

4.3.3. DefaultUserLevelUpgradePolicyTest.java

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
class DefaultUserLevelUpgradePolicyTest {

    @Autowired
    IUserDao userDao;

    @Autowired
    DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;

    List<User> users; // 테스트 픽스처

    public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

    public static final int MIN_RECCOMEND_FOR_GOLD = 30;
    @BeforeEach
    void setUp(){

        users = Arrays.asList(
                new User("test1","테스터1","pw1", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER-1, 0, "tlatmsrud@naver.com"),
                new User("test2","테스터2","pw2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0, "tlatmsrud@naver.com"),
                new User("test3","테스터3","pw3", Level.SILVER, 60, MIN_RECCOMEND_FOR_GOLD-1, "tlatmsrud@naver.com"),
                new User("test4","테스터4","pw4", Level.SILVER, 60, MIN_RECCOMEND_FOR_GOLD, "tlatmsrud@naver.com"),
                new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
        );


    }
    @Test
    void canUpgradeLevel() {

        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(0))).isFalse();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(1))).isTrue();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(2))).isFalse();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(3))).isTrue();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(4))).isFalse();

    }

    @Test
    void upgradeLevel() {

        userLevelUpgradePolicy.upgradeLevel(users.get(1));
        assertThat(users.get(1).getLevel()).isEqualTo(Level.SILVER);

        userLevelUpgradePolicy.upgradeLevel(users.get(3));
        assertThat(users.get(3).getLevel()).isEqualTo(Level.GOLD);
    }
}

 

 테스트 코드는 기존과 바뀐게 없다. 이로써 아래와 같이 MailSender 인터페이스를 핵심으로 하는 메일 전송 서비스 추상화 구조가 아래와 같이 구성되었다.

추상화 구조 UML

 

 

4.4. 테스트

 테스트가 성공했을 때 메일 발송은 되지 않는 걸 확인할 수 있을 것이다.

 


5. 테스트 대역

5.1. 테스트 대역이란?

 그런데 사실 이제까지 작성한 테스트 코드는 문제점이 하나 있다. 바로 테스트의 범위이다. UserServiceTest 클래스의 경우 UserService 클래스의 메서드를 테스트하는 코드이다. 하지만 그와 동시에 의존하는 UserLevelUpgradePolicy의 실제 로직도 수행된다. 사실 UserServiceTest 클래스의 관심사는 오로지 UserService 클래스의 로직이어야 한다. 의존하는 객체에 대해서는 그 클래스의 Test 코드에서 수행하는게 맞다.

 테스트 대상 오브젝트에는 여러 의존 오브젝트들이 있을 수 있다. 이때에는 의존 오브젝트에 대한 '대역' 역할을 하는 오브젝트를 만들어 테스트가 이상없이 동작되도록 해야한다. 이러한 오브젝트들을 테스트 대역(test double) 이라고 한다. 

 

5.2. 테스트 대역의 종류

테스트 대역의 종류

 

 

5.2.1. Dummy

 아무런 동작을 하지 않는 테스트 대역이다. 인스턴스화된 객체는 필요하지만 기능은 굳이 필요없는 경우에 사용한다. 앞서 생성한 DummyMailSender가 Dummy에 속한다.

public class DummyMailSender implements MailSender{
    @Override
    public void send(SimpleMailMessage simpleMessage) throws MailException {

    }

    @Override
    public void send(SimpleMailMessage... simpleMessages) throws MailException {

    }
}

 

5.2.2. Fake

 동작은 하지만, 실제 동작 방식과 다르게 구현하는 테스트 대역이다. DB 데이터 처리를 Fake로 만들고자 한다면 Repository 인터페이스에 대한 구현체 클래스를 만들고 내부에 ArrayList와 같은 멤퍼 필드를 생성하여 인메모리로 관리할 수 있다. 즉, DB가 아닌 다른 방식으로 구현하는 것이다.

 아래는 데이터를 실제 DB가 아닌 인메모리에 저장한 list로 처리하도록 구현한 예이다.

public class FakeUserDao implements IUserDao{
    
    private final List<User> list = new ArrayList<>();

    @Override
    public void add(User user) {
        list.add(user);
    }

    @Override
    public void deleteAll() {
        list.clear();
    }

    @Override
    public User get(String id) {

        return list.stream()
                .filter(user -> id.equals(user.getId()))
                .findFirst()
                .get();
    }

    @Override
    public int getCount() {
        return list.size();
    }

    ...
}

 

5.2.3. Stub

 Dummy 객체가 실제로 동작하는 것처럼 구현하는 테스트 대역이다. 어떤 메서드를 호출했을 때 반환 값이 없을 경우 Dummy를 사용해도 되지만, 반환 값이 있거나 네거티브 테스트로 인해 예외를 발생시켜야 할 경우에는 기존 Dummy 객체에 의도한 동작을 구현해줘야 한다.

 아래는 ID가 100인 사용자 정보를 요청할 경우 RuntimeException을 발생시키고, 그 외에는 무조건 테스터 유저에 대한 픽스처를 생성 후 리턴하는 예이다.

public class StubUserDao implements IUserDao{
    
    ...

    @Override
    public User get(String id) {
        
        if("100".equals(id)){
            throw new RuntimeException("삭제된 ID입니다.");
        }
        return new User("1", "테스터","비밀번호", Level.BASIC,0 ,0,"test@naver.com");
    }
    
    ...
}

 

 

5.2.4. Spy

 기본적으론 실제 오브젝트처럼 동작한다. 대신, 원하는 메서드에 Stubbing 처리를 하여 응답을 미리 지정할 수 있다. 즉, Subbing이라는 스파이를 지정할 수 있는 테스트 대역이다. mockito 라이브러리에서 제공하는 SpyBean 어노테이션을 사용하면 쉽게 구현할 수 있다.

 아래는 upgradeLevel() 메서드 호출 시 users.get(3)에 대해서만 RuntimeException을 발생시키고, 나머지는 실제 로직을 수행시키도록 하는 예이다. given 메서드를 사용하여 upgradeLevel에 대한 Stub를 지정하고 있다.

    @SpyBean
    DefaultUserLevelUpgradePolicy spyUserLevelUpgradePolicy;
    
    ...
    
    @Test
    @DisplayName("레벨업 처리 도중 예외 발생 테스트")
    void exceptionDuringLevelUp(){
        ...
        
        // users.get(3) 오브젝트로 upgradeLevel 메서드를 호출하면 RuntimeException을 발생시키도록 Stubbing
        given(spyUserLevelUpgradePolicy.upgradeLevel(users.get(3))).willThrow(new RuntimeException());

        /* upgradeLevels 메서드 내부에서 spyUserLevelUpgradePolicy.upgradeLevel()를 호출하고 있음.
         * users.get(0), users.get(1), users.get(2)에 대한 매개변수로 호출 시 실제 로직이 실행되나, 
         * users.get(3) 으로 호출 시 RuntimeException이 발생함.
        */
        userService.upgradeLevels();

        ...
    }

 

5.2.5. Mock

 가짜 객체를 만든 후 Stubbing을 통해 미리 명세를 정의해 놓은 대역을 말한다. Stub와 다른 점은 메서드의 리턴 값으로는 판단할 수 없는 행위를 검증할 수 있다는 점이다. 예제에서는 리턴 값이 없어 메일 발송 내역 확인이 어려운 DummyMailSender 클래스 대신 발송 내역을 확인할 수 있는, 즉 어떤 행위를 했는지를 명확히 검증하기 위해 MockMailSender 클래스를 생성하였다. 테스트 코드에서는 requests를 조회한 후 누구에게 발송했는지를 확인할 수 있다.

public class MockMailSender implements MailSender {

    private final List<String> requests = new ArrayList<>();

    @Override
    public void send(SimpleMailMessage simpleMessage) throws MailException {
        requests.add(simpleMessage.getTo()[0]);
    }

    @Override
    public void send(SimpleMailMessage... simpleMessages) throws MailException {

    }

    public List<String> getRequests(){
        return requests;
    }
}

 

 추가로 mockito 라이브러리에서 제공하는 기능 중 Mock 객체에 대해 특정 메서드를 어떤 파라미터로 호출 했는지를 검증할 수 있는 기능을 활용할 수 있다. 아래의 경우 userService.upgradeLevels() 내부에서 호출되는 mockUserLevelUpgrdePolicy라는 Mock 객체에 대해 users.get(3)을 파라미터로 하여 upgradeLevel 메서드가 호출됐는지를 확인한다. 이처럼 Mock은 상태 뿐 아닌 행위까지 검증할 수 있다.

 

    @Test
    void exceptionDuringLevelUp(){
        ...
        
        userService.upgradeLevels();

        verify(mockUserLevelUpgradePolicy).upgradeLevel(users.get(3));
    }

 

 

반응형
반응형

1. 개요

 정기 사용자 레벨 관리 작업을 수행하는 도중 네트워크가 끊기거나 중간 로직에서 예외가 발생한다면, 그때까지 변경된 사용자의 레벨은 그대로 둘까? 모두 롤백할까?

 당연히 모두 롤백해야 한다. 일부 사용자만 레벨이 조정됐다면 사용자의 반발이 우려되기 때문이다. 롤백 후 다시 일정을 잡아 해당 작업을 수행해야 한다. 현재 JdbcTemplate을 사용해 정기 사용자 레벨 관리 작업에 대한 데이터 처리 작업을 하고있다. 작업 중간에 예외가 발생했을때 실제로 모두 롤백이 되는지 확인해보고, 롤백이 되지 않는다면 원인을 파악해보자.


2. 롤백 테스트

 2.1. 테스트 케이스

  정기 사용자 레벨 관리 작업은 모든 유저 조회, 레벨업 조건 만족 여부 체크, 레벨업 데이터 처리 과정을 거친다. 이에따라 조건을 만족하는 N개의 유저 픽스처를 미리 생성한 후 조건을 만족하는 특정 유저에 대해 레벨업 처리 데이터 처리 시 RuntimeException을 발생시키도록 하는 테스트 케이스를 구현하였다.

 교재에서는 서비스 클래스를 상속받고, 특정 유저 ID로 레벨 업 처리 시 예외가 발생하도록 하였으나, 필자는 실제 로직은 건들고 싶지 않아 테스트 코드만으로 처리하였다.

 

2.2. 테스트 코드

    @Autowired
    UserService userService;

    @SpyBean
    DefaultUserLevelUpgradePolicy spyUserLevelUpgradePolicy;

    @SpyBean
    IUserDao spyUserDao;
    
    List<User> users; // 테스트 픽스처
    
    @BeforeEach
    void setUp(){
       users = Arrays.asList(
               new User("test1","테스터1","pw1", Level.BASIC, 49, 0),
               new User("test2","테스터2","pw2", Level.BASIC, 50, 0),
               new User("test3","테스터3","pw3", Level.SILVER, 60, 29),
               new User("test4","테스터4","pw4", Level.SILVER, 60, 30),
               new User("test5","테스터5","pw5", Level.GOLD, 100, 100),
               new User("test6","테스터6","pw6", Level.PLATINUM, 100, 100)
       );
    }

    @Test
    @DisplayName("레벨업 처리 도중 예외 발생 테스트")
    void exceptionDuringLevelUp(){
       	userService.setUserLevelUpgradePolicy(spyUserLevelUpgradePolicy); // spyBean 설정

		// Stubbing : userDao.getAll() 호출 시 users 리턴
        given(spyUserDao.getAll()).willReturn(users); 
       
        // Stubbing : 네번째 유저에 대한 레벨업 메서드 호출 시 RuntimeException 발생
        given(spyUserLevelUpgradePolicy.upgradeLevel(users.get(3))).willThrow(new RuntimeException());
	
    	// 모든 유저 삭제
        spyUserDao.deleteAll();
        
        // 유저 등록
        users.forEach(user -> spyUserDao.add(user));

		// 정기 레벨업 작업
        userService.upgradeLevels();

        checkLevelUpgraded(users.get(1),false);
        checkLevelUpgraded(users.get(3),false); 
    }
    
    private void checkLevelUpgraded(User user, boolean upgraded){
        User userUpdate = userDao.get(user.getId());

        if(upgraded){
            assertThat(userUpdate.getLevel()).isEqualTo(user.getLevel().getNexeLevel());
        }else{
            assertThat(userUpdate.getLevel()).isEqualTo(user.getLevel());
        }
    }
...

  네번째 유저에 대해 RuntimeException이 발생하도록 하고, checkLevelUpgraded 메서드를 통해 유저의 레벨 업 여부를 체크한다. 첫번째 파라미터는 체크할 유저, 두번째 파라미터는 레벨업 여부이다. 만약 users.get(1) 유저가 레벨업이 된다면 테스트는 실패할 것이다.

 

2.3. 테스트 결과

 users.get(1) 유저가 레벨업 되었기때문에 테스트는 실패했다. 이로써 기본적인 jdbcTemplate을 사용할 경우 중간에 예외가 발생해도 롤백이 되지 않는다는 사실을 알게 되었다.

 


3. 롤백이 되지 않는 이유

 

3.1. 트랜잭션

 그렇다면 롤백이 되지 않는 이유는 뭘까? 바로 트랜잭션 때문이다. 모든 사용자의 레벨을 업그레이드하는 작업인 upgradeLevels() 메서드가 하나의 트랜잭션 안에서 동작하지 않았기 때문이다. 

 

트랜잭션이란?
데이터베이스의 상태를 변화시키는 하나의 논리적 기능을 수행하기 위한 작업의 단위를 말한다.

 

 만약 upgradeLevels()이 하나의 트랜잭션으로 구성되었다면 '하나의 트랜잭션은 모두 성공하거나 또는 모두 실패해야한다'는 트랜잭션 원자적 성질에 따라 모두 실패했겠지만, 여러개의 트랜잭션으로 구성되었기 때문에 모두 롤백될 수가 없었다. 결국 이를 해결하기 위해서는 트랜잭션의 원자적 성질을 적용시키기 위해upgradeLevels() 메서드에서 일어나는 모든 데이터 처리에 대해 하나의 트랜잭션으로 구성되도록 설정해야한다. 아래와 같이 말이다.

public void upgradeLevels() {

    // 트랜잭션 시작
    
    // 비지니스 로직(모든 유저 조회, 레벨업 조건 만족 여부 체크, 레벨업 처리)
    
    // 트랜잭션 종료
}

 

3.2. JdbcTemplate의 트랜잭션

 JdbcTemplate의 메서드 호출 한번에 한 개의 DB 커넥션이 만들어지고 닫힌다. 일반적으로 트랜잭션은 커넥션보다도 존재 범위가 짧은데, 템플릿 메서드가 호출될 때마다 트랜잭션이 만들어지고, 메서드를 빠져 나오기 전에 종료되게 된다. 즉, jdbcTemplate의 메서드를 사용하는 UserDao는 각 메서드마다 하나의 독립적인 트랜잭션으로 실행되었기 때문에 롤백이 되지 않았던 것이다.

 아래는 JdbcTemplate 메서드 실행 시 호출되는 execute 메서드이다. Connection이 생성되고, 반환되는 것을 확인할 수 있다. 

그런데 조금 특이한 부분이 있다. 일반적으로 Connection을 생성할 때는 DataSource 객체를 사용하는데 여기서는 DataSourceUtils 객체를 사용하여 호출하고 있다. 이에 대해 구글링하니 다음과 같은 답변을 찾아볼 수 있었다.

DataSource.getConnection() 는 DataSource 또는 연결 풀에서 얻은 새 연결을 반환합니다.
DataSourceUtils.getConnection() 현재 스레드에 대한 활성 트랜잭션이 있는지 확인합니다.
있는 경우 활성 트랜잭션에 대한 Connection을 반환하고, 없을 경우 DataSource와 같은 방식으로 작동합니다.

출처 - https://stackoverflow.com/questions/9642643/datasourceutils-getconnection-vs-datasource-getconnection

스레드에 활성 트랜잭션이 있을 경우 이 트랜잭션에 대한 Connection을 사용한다는 뜻이다. JdbcTemplate 메서드 호출 시 무조건 새로운 Connection을 생성하지 않았다. 이 활성 트랜잭션을 활용하는 방법이 있는데 이게 바로 트랜잭션 동기화이다.


4. 트랜잭션 동기화

 

4.1. 트랜잭션 동기화란

 스프링은 트랜잭션을 시작하기 위해 만든 Connection을 동기화 저장소에 보관해두고, 이후에 호출되는 다른 메서드에서 Connection을 사용해야 할 경우 저장된 Connection을 가져다 사용하게 하여 하나의 트랜잭션으로 관리할 수 있는 방법을 제공한다. 이를 트랜잭션 동기화라고 한다.

 

4.2. 트랜잭션 동기화 적용

 스프링은 멀티스레드 환경에서도 안전하게 트랜잭션을 동기화하는 기능을 지원한다. TransactionSynchronizationManager와 앞서 찾아보았던 DataSourceUtils이다.

TransactionSynchronizationManager를 이용해 트랜잭션 동기화 작업을 초기화하도록 요청하고, DataSourceUtils에서 제공하는 getConnection() 메서드를 통해 DB 커넥션을 생성한다. DataSource에서 Connection을 직접 가져오지 않고, 이를 이용하는 이유는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.

 

 트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate 메서드 호출 시 동기화시킨 Connection을 사용하게 된다. 즉, upgradeLevels() 에서 트랜잭션 동기화 작업을 한다면, 내부에서 호출되는 JdbcTemplate은 upgradeLevels()에서 만든 Connection을 사용하게 되어 하나의 트랜잭션으로 돌아가게 된다.

 

4.3. 코드 적용

 DataSourceUtils 메서드에 필요한 dataSource를 사용하기 위해 수정자 메서드를 추가 및 DI 설정 후, 트랜잭션 동기화 로직을 추가하였다. 이제 테스트 코드를 통해 롤백이 되었는지 확인해보자. 아마 롤백이 되어 테스트가 성공할 것이다.

public class UserService {
	...
    private DataSource dataSource; // dataSource 추가

    public void setDataSource(DataSource dataSource){ // dataSource 수정자 메서드 추가
        this.dataSource = dataSource;
    }
    ...
	public void upgradeLevels() {
        TransactionSynchronizationManager.initSynchronization(); // 동기화 작업
        Connection c = DataSourceUtils.getConnection(dataSource); // 커넥션 생성 및 저장소 바인딩

        try{
            c.setAutoCommit(false);

            List<User> users = userDao.getAll();

            for(User user : users) {

                if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                    userLevelUpgradePolicy.upgradeLevel(user);
                }
            }
            c.commit();
        }catch(Exception e){
            try{
                c.rollback();
            }catch (SQLException ex){
                e.printStackTrace();
            }
        }finally {
            DataSourceUtils.releaseConnection(c, dataSource); // DB Connection close
            TransactionSynchronizationManager.unbindResource(this.dataSource); // 트랜잭션 저장소에서 데이터소스 반환
            TransactionSynchronizationManager.clearSynchronization(); // 동기화 작업 종료
        }
    }

 


5. 트랜잭션 서비스 추상화

 그렇다면 2개 이상의 DB를 하나의 트랜잭션으로 묶을 수 있을까?

 현재 방법으로는 불가능하다. 트랜잭션이 하나의 DB Connection에 종속되는 방식인 로컬 트랜잭션 방식을 사용하기 때문이다. 이 경우 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션 방식을 사용해야 한다. 자바는 글로벌 트랜잭션을 지원하는 API인 JTA(Java Transaction API)를 제공한다.

 하지만 다짜고짜 JTA를 적용하는 것도 문제가 된다. JTA를 적용하려면 서비스 로직이 수정되어야하는데 기존 로컬 트랜잭션 방식을 사용하는 경우 굳이 수정할 필요가 없기 때문이다.

 스프링은 이러한 다른 트랜잭션 방식에 대해서도 추상화하여 사용할 수 있도록 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용하면 로컬, 글로벌과 같은 트랜잭션 방식 뿐 아니라 JDBC, JPA, 하이버네이트 등과 같은 기술들의 각 트랜잭션 API를 이용하지 않고도 일관된 방식으로 트랜잭션 제어가 가능해진다.

 

5.1. PlatformTransactionManager

 스프링이 제공하는 트랜잭션 처리를 위한 추상 인터페이스이다. JDBC의 로컬 트랜잭션을 이용한다면 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하고 JTA를 이용하는 글로벌 트랜잭션을 이용한다면 JTATransactionManager로 구현체를 바꿔 사용하면 된다.

 

5.2. DataSourceTransactionManager 적용

 

5.2.1) UserService.java

public class UserService {

    private IUserDao userDao;


    private PlatformTransactionManager transactionManager;

    public void setTransactionManager(PlatformTransactionManager transactionManager){
        this.transactionManager = transactionManager;
    }

    ...

    public void upgradeLevels() {

        // DI 받은 트랜잭션 매니저를 공유해서 사용한다.
        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );

        try{
            List<User> users = userDao.getAll();

            for(User user : users) {

                if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                    userLevelUpgradePolicy.upgradeLevel(user);
                }
            }
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }

    public void add(User user) {
        if(user.getLevel() == null){
            user.setLevel(Level.BASIC);
        }
        userDao.add(user);
    }
}

 

5.2.2) applicationContext.xml

<bean id = "userService" class = "org.example.user.service.UserService">
    <property name="userDao" ref = "userDao"></property>
    <property name="userLevelUpgradePolicy" ref = "userLevelUpgradePolicy"></property>
    <property name="transactionManager" ref="transactionManager"></property>
</bean>

<bean id = "transactionManager" class ="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource"></property>
</bean>

...

 

 필자의 경우 DataSource를 통한 로컬 트랜잭션 방식을 사용해야 하므로 transactionManager의 구현체 클래스를 DataSourceTransactionManager로 하는 Bean을 생성하였다. 해당 클래스 생성 시 dataSource가 필요하므로 property를 통해 주입시켰다.

 마지막으로 테스트를 통해 transactionManager가 적용됐음을 확인해보자.

 

 

 

 

반응형
반응형

1. 개요

 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 사용할 수 있는지에 대한 서비스 추상화 방법을 여러 기능을 추가해보면서 알아본다.


2. 사용자 레벨 관리 기능 추가

 사용자의 활동 내역을 참고해서 레벨을 조정해 주는 요구사항이 추가되었다. 상세 내용은 다음과 같다.

더보기

1. 사용자의 레벨은 BASIC, SILVER, GOLD 세가지이다.

2. 사용자가 처음 가입하면 BASIC 레벨이 되며 이후 활동에 따라 한단계씩 업그레이드 된다.

3. 가입 후 50회 이상 로그인을 하면 BASIC에서 SILVER 레벨이 된다.

4. SILVER 레벨이면서 30번 이상 추천을 받으면 GOLD 레벨이 된다.

5. 사용자의 레벨 변경 작업은 일정한 주기를 가지고 일괄적으로 진행된다. 작업 전에는 조건을 충족하더라도 레벨의 변경이 일어나지 않는다.

 

2.1. 상수는 Enum 클래스로

 Level은 상수로 관리해야 하므로 상수 관리에 적합한 Enum 클래스로 관리한다. 교재에서는 DB에 정수 형태로 저장하기 위해 value 필드를 만들어 BASIC은 1, SILVER는 2, GOLD는 3으로 관리하고 있다.

 

public enum Level{
	GOLD(3),
    SILVER(2),
    BASIC(1);
    
    private final int value;
    
    Level(int value){
    	this.value = value;
    }
    
    public int intValue(){ // DB 저장 시 값을 가져오기 위한 메서드
    	return value;
    }
    
    public static Level valueOf(int value){ // value 값으로 레벨을 조회하는 메서드
        switch (value){
            case 1 : return BASIC;
            case 2 : return SILVER;
            case 3 : return GOLD;
            default: throw new AssertionError("Unknown value : "+value);
        }
    }
}

 

2.2. User 필드 추가

 유저의 레벨 관리를 위해 Level 타입의 멤버필드를 User 클래스에 추가한다. 또한 로그인, 추천 횟수 정보를 담을 login, recommend 도 추가한다.

public class User {

    private String id;
    private String name;
    private String password;
    private Level level;
    private int login;
    private 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
}

 

2.3. 비지니스 로직은 Service로 관리

 레벨 변경 작업은 레벨을 업그레이드 하기 위한 조건들을 체크하는 비지니스성 작업이다. 이러한 비지니스 로직은 Service 클래스로 관리해야한다. DAO 클래스는 DB 데이터를 가져오고 조작하는 작업을 담당한다. 만약 비지니스 로직 중 DB를 접근해야 하는 부분이 있다면, Service 클래스에서 DAO 클래스를 참조하는 형태가 되어야 한다. 테스트 코드도 구현한다고 가정하면 아래와 같은 형태의 UML로 구성된다.

Service를 추가한 UML

 

 구현을 위해 UserService 클래스를 생성하고 조건을 체크하여 레벨을 업그레이드하는 upgradeLevels() 메서드를 생성하였다.

public class UserService {

    private IUserDao userDao;

    public void setUserDao(IUserDao userDao){ // userDao DI를 위한 메서드
        this.userDao = userDao;
    }

    public void upgradeLevels() {
        
        // 구현해야하는 비지니스 로직
        
    }

}

 

2.4. upgradeLevels() 구현

 레벨 업에 대한 비지니스 로직을 upgradeLevels() 메서드 안에 녹였지만 if, else if 구문 내용에 여러 관심이 섞여버리는 문제점이 있다.

 1) 현재 레벨을 체크하는 user.getLevel() == Level.BASIC, user.getLevel() == Level.SILVER, 그와 함께 레벨 업 조건을 체크하는 user.getLogin() >= 50, user.getRecommend() >= 30.

 2) user의 레벨 값을 변경하는 user.setLevel().

 3) userDao.update(user) 호출 여부를 체크하는 changed.

하나의 조건문 안에 여러 관심들이 섞여있다. 이를 분리하는 것이 좋아보인다.

public void upgradeLevels(){

    List<User> users = userDao.getAll();
     
     for(User user : users){
     
     	Boolean changed = false;
        
     	if(user.getLevel() == Level.BASIC && user.getLogin() >= 50){
	    user.setLevel(Level.SILVER);
            changed = true;
        }else if(user.getLevel() == Level.SILVER && user.getRecommend() >= 30){
 	    user.setLevel(Level.GOLD);
            changed = true;
        }
        
        if(changed){
	    userDao.update(user);
        }
     }
}

 

2.5. upgradeLevels() 리팩토링

 기존 메서드는 자주 변경될 가능성이 있는 구체적인 내용추상적인 로직의 흐름과 함께 섞여있다.

 

 * 추상적인 로직 - userDao.getAll(), userDao.update()

 * 구체적인 내용 - 레벨 체크, 레벨 변경 등과 같은 비지니스 로직

 

구체적인 내용을 메서드화 하여 추상적으로 만들어보자.

...

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

        for(User user : users) {
            if (canUpgradeLevel(user)) { // 업그레이드 가능 여부 체크
                upgradeLevel(user); // 업그레이드 처리
            }
        }
    }

 

이제 추상화한 메서드인 canUpgradeLevel과 upgradeLevel의 비지니스 로직을 구현한다.

    private static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

    private 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_LOGIN_COUNT_FOR_SILVER);
            case SILVER: return (user.getRecommend() >= MIN_RECOMMEND_FOR_GOLD);
            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);
    }

  canUpgradeLevel은 레벨 업 조건을 만족하는지 체크한다. 이전과는 다르게 switch case 문을 사용해 현재 사용자 레벨 체크와 레벨업 조건 체크를 분리하였다. 그리고 알수없는 레벨 값이 들어왔을 때에는 예외처리도 하고 있다.

 upgradeLevel은 레벨 업 작업을 담당하는데 이 부분도 추상적인 로직과 비지니스 로직이 섞여있다. 유저의 레벨을 체크하는 부분, 유저의 레벨을 수정자 메서드로 set하는 부분이다.

 또다른 문제점도 있다. 알수없는 레벨에 대한 예외 처리가 되어 있지 않으며, 레벨이 늘어나면 if문도 점점 늘어날 수 있다는 것이다.

 

2.6. upgradeLevel 리팩토링

1) 레벨 관리는 Level에서. (= 자기의 일은 스스로하자)

 '다음 레벨 조회'에 대한 책임을 가진 클래스가 없기에 레벨 업을 위해 다음 레벨을 조회하는 로직이 if문 내 하드코딩으로 구현되어있다. 이에 대한 책임은 Level에서 갖도록 해야한다. 한단계 높은 레벨을 뽑아내는건 UserService가 하는것보다 Level 스스로 하는게 낫기 때문이다. 이제 다음 단계의 레벨이 무엇인지를 찾기 위해 if 조건식을 만들어서 비지니스 로직에 담을 필요가 없다.

public enum Level {

    GOLD(3, null),
    SILVER(2, GOLD),
    BASIC(1, SILVER);

    private final int value;
    private final Level nextLevel;

    Level(int value, Level nextLevel){
        this.value = value;
        this.nextLevel = nextLevel;
    }
    
    public Level getNextLevel(){
    	return this.nextLevel;
    }
    ...
}

 

 

2) User의 변경 책임은 User에서 (= 자기의 일은 스스로하자)

 User의 내부 정보를 변경하기 위해 UserService에서 user.setLevel()을 호출하고 있지만, 앞서 Level과 마찬가지로 User의 정보가 변경되는 것은 User 스스로 다루는 게 적절하다. UserService가 레벨 업그레이드 시에 user의 어떤 필드를 수정하는 로직을 갖고 있기보다는, User에게 레벨 업그레이드를 요청하는 편이 낫다.

public class User {

    ... 
    public void upgradeLevel(){
        Level nextLevel = this.level.getNextLevel();
        if(nextLevel == null){
            throw new IllegalStateException(this.level +"은 업그레이드가 불가능합니다.");
        }
        this.level = nextLevel;
    }
}

 

3) upgradeLevel() 리팩토링

 위 두가지를 적용한 후 upgradeLevel() 메서드를 리팩토링하니 훨씬 간결하고 추상적인 메서드가 되었다.

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


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

 

 이제 오브젝트와 메서드가 자기 몫의 책임을 맡아 처리하는 구조로 만들어졌음을 확인할 수 있다. UserService, User, Level이 내부 정보를 다룰 때는 자신이 처리하고, 외부 정보가 필요할 때에는 외부로 작업을 요청하는 구조이다. 

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


3. 유연한 레벨 업그레이드 정책

 레벨 업그레이드 정책을 유연하게 변경할 수 있도록 구현하는 실습을 진행하였다. 기본은 현행을 유지하되 이벤트 기간에는 레벨업 정책을 다르게 적용할 수 있도록 말이다. 필자는 아래와 같은 요구사항을 자체적으로 수립하였고 실습을 진행하였다.

더보기

이벤트 기간에 대해서는 아래와 같은 정책이 적용되도록 한다.

1. BASIC -> SILVER 레벨업 시 로그인 횟수 50번에서 30번으로 하향한다.

2. SILVER -> GOLD 레벨업 시 추천 횟수 30번에서 20번으로 하향한다.

3. 이벤트 기간에 한해 GOLD에서 PLATINUM으로 레벨업 가능하며 조건은 추천 횟수 100번이다.

 

3.1. UserLevelUpgradePolicy 인터페이스 구현

전략 패턴을 사용하기 위해 UserLevelUpgradePolicy 인터페이스를 구현하였다. 패키지는 뭘로할까 고민하다가 Service에서 사용되는 하나의 정책 속성이라고 생각하여 attribute라는 패키지를 만들어 구성하였다. 업그레이드 기능과 연관된 메서드인 canUpgradeLevel()과 upgradeLevel() 메서드를 인터페이스 메서드로 구성하였다.

public interface UserLevelUpgradePolicy {
    
    boolean canUpgradeLevel(User user);

    void upgradeLevel(User user);
}

 

3.2. 기본 레벨 업그레이드 정책 클래스 구현

 UserLevelUpgradePolicy 를 상속받은 DefaultUserLevelUpgradePolicy 클래스를 만들고, 구현했던 로직을 이관시켰다. 레벨 업 조건이 바뀌었으므로 상수 값을 수정하였고, UserDao DI를 위한 수정자 메서드 추가, canUpgradeLevel에는 새롭게 추가될 등급인 PLATINUM도 추가하였다.

 기본 정책에서 PLATINUM 레벨은 추가되지 않지만, 이벤트 기간 후 다시 기본 정책으로 돌아왔을 때 아래 클래스를 사용하게 될텐데, 이때 PLATINUM 조건이 없다면 canUpgradeLevel 메서드에서 예외가 발생할 것이기 때문에 해당 레벨도 switch 문에 추가하였다. 

public class DefaultUserLevelUpgradePolicy implements UserLevelUpgradePolicy{

    private IUserDao userDao;

    private static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

    private static final int MIN_RECOMMEND_FOR_GOLD = 30;
    
    public void setUserDao(IUserDao userDao){
        this.userDao = userDao;
    }

    public boolean canUpgradeLevel(User user){
        Level currentLevel = user.getLevel();

        switch(currentLevel){
            case BASIC: return (user.getLogin() >= MIN_LOGIN_COUNT_FOR_SILVER);
            case SILVER: return (user.getRecommend() >= MIN_RECOMMEND_FOR_GOLD);
            case GOLD:
            case PLATINUM:
                return false;
            default: throw new IllegalArgumentException("Unknown Level :"+currentLevel);
        }
    }

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

 

 

3.3. 이벤트 레벨 업그레이드 정책 클래스 구현

 UserLevelUpgradePolicy 를 상속받은 EventUserLevelUpgradePolicy 클래스를 만들었다. 레벨 업 조건이 바뀌었으므로 상수 값을 수정하였고, UserDao DI를 위한 수정자 메서드 추가, canUpgradeLevel에는 새롭게 추가될 등급인 PLATINUM도 추가하였다. 

public class EventUserLevelUpgradePolicy implements UserLevelUpgradePolicy{

    private IUserDao userDao;


    private static final int MIN_LOGIN_COUNT_FOR_SILVER = 30;
    private static final int MIN_RECOMMEND_FOR_GOLD = 20;
    private static final int MIN_RECOMMEND_FOR_PLATINUM = 100;

    public void setUserDao(IUserDao userDao){
        this.userDao = userDao;
    }

    public boolean canUpgradeLevel(User user){
        Level currentLevel = user.getLevel();

        switch(currentLevel){
            case BASIC: return (user.getLogin() >= MIN_LOGIN_COUNT_FOR_SILVER);
            case SILVER: return (user.getRecommend() >= MIN_RECOMMEND_FOR_GOLD);
            case GOLD: return (user.getRecommend() >= MIN_RECOMMEND_FOR_PLATINUM);
            case PLATINUM: return false;

            default: throw new IllegalArgumentException("Unknown Level :"+currentLevel);
        }
    }

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

 

3.4. DI 설정 정보 수정

 DI 설정 정보를 관리하는 DaoFactory 클래스에 UserLevelUpgradePolicy에 대한 Bean을 생성하고, UserService에서 이를 DI 받도록 구성하였다. 이제 이벤트 기간에는 UserLevelUpgradePolicy의 구현체 클래스를 EventUserLevelUpgradePolicy로 설정하면 된다.

@Configuration
public class DaoFactory {
    @Bean
    public UserDaoJdbc userDao(){
        UserDaoJdbc userDaoJdbc = new UserDaoJdbc();
        userDaoJdbc.setDataSource(dataSource());
        return userDaoJdbc;
    }

    @Bean
    public DataSource dataSource(){
        SimpleDriverDataSource dataSource = new SimpleDriverDataSource();

        dataSource.setDriverClass(com.mysql.jdbc.Driver.class);
        dataSource.setUrl("jdbc:mysql://localhost/xxx");
        dataSource.setUsername("test");
        dataSource.setPassword("test");

        return dataSource;
    }

    @Bean
    public UserService userService(){
        UserService userService = new UserService();
        userService.setUserDao(userDao());
        userService.setUserLevelUpgradePolicy(userLevelUpgradePolicy());
        return userService;
    }

    @Bean
    public UserLevelUpgradePolicy userLevelUpgradePolicy(){
        DefaultUserLevelUpgradePolicy policy = new DefaultUserLevelUpgradePolicy();
        policy.setUserDao(userDao());
        return policy;
    }
}

 

3.5. 테스트 코드

UserLevelUpgradePolicy 구현체에 대한 테스트 코드는 각각 생성하였다. 레벨업 조건이 다르기 때문에 테스트 픽스처와 조건 상수 수정이 필요했기 때문이다.

UserService에 대한 테스트 코드는 UserLevelUpgradePolicy 구현체에 따라 결과가 달라지므로 테스트 메서드 레벨에서 UserLevelUpgradePolicy 구현체를 분리하도록 구현하였다.

 

1) DefaultUserLevelUpgradePolicyTest.java

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
class DefaultUserLevelUpgradePolicyTest {

    @Autowired
    IUserDao userDao;

    DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;

    List<User> users; // 테스트 픽스처

    public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

    public static final int MIN_RECCOMEND_FOR_GOLD = 30;
    @BeforeEach
    void setUp(){

        userLevelUpgradePolicy = new DefaultUserLevelUpgradePolicy();
        userLevelUpgradePolicy.setUserDao(userDao);

        users = Arrays.asList(
                new User("test1","테스터1","pw1", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER-1, 0),
                new User("test2","테스터2","pw2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0),
                new User("test3","테스터3","pw3", Level.SILVER, 60, MIN_RECCOMEND_FOR_GOLD-1),
                new User("test4","테스터4","pw4", Level.SILVER, 60, MIN_RECCOMEND_FOR_GOLD),
                new User("test5","테스터5","pw5", Level.GOLD, 100, 100)
        );


    }
    @Test
    void canUpgradeLevel() {

        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(0))).isFalse();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(1))).isTrue();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(2))).isFalse();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(3))).isTrue();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(4))).isFalse();

    }

    @Test
    void upgradeLevel() {

        userLevelUpgradePolicy.upgradeLevel(users.get(1));
        assertThat(users.get(1).getLevel()).isEqualTo(Level.SILVER);

        userLevelUpgradePolicy.upgradeLevel(users.get(3));
        assertThat(users.get(3).getLevel()).isEqualTo(Level.GOLD);
    }
}

 

2) EventUserLevelUpgradePolicyTest.java

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
class EventUserLevelUpgradePolicyTest {

    @Autowired
    IUserDao userDao;

    EventUserLevelUpgradePolicy userLevelUpgradePolicy;

    List<User> users; // 테스트 픽스처

    private static final int MIN_LOGIN_COUNT_FOR_SILVER = 30;
    private static final int MIN_RECOMMEND_FOR_GOLD = 20;
    private static final int MIN_RECOMMEND_FOR_PLATINUM = 100;
    @BeforeEach
    void setUp(){

        userLevelUpgradePolicy = new EventUserLevelUpgradePolicy();
        userLevelUpgradePolicy.setUserDao(userDao);

        users = Arrays.asList(
                new User("test1","테스터1","pw1", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER-1, 0),
                new User("test2","테스터2","pw2", Level.BASIC, MIN_LOGIN_COUNT_FOR_SILVER, 0),
                new User("test3","테스터3","pw3", Level.SILVER, 60, MIN_RECOMMEND_FOR_GOLD-1),
                new User("test4","테스터4","pw4", Level.SILVER, 60, MIN_RECOMMEND_FOR_GOLD),
                new User("test5","테스터5","pw5", Level.GOLD, 100, MIN_RECOMMEND_FOR_PLATINUM-1),
                new User("test6","테스터6","pw6", Level.GOLD, 100, MIN_RECOMMEND_FOR_PLATINUM),
                new User("test7","테스터7","pw7", Level.PLATINUM, 100, 120)
        );


    }
    @Test
    void canUpgradeLevel() {

        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(0))).isFalse();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(1))).isTrue();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(2))).isFalse();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(3))).isTrue();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(4))).isFalse();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(5))).isTrue();
        assertThat(userLevelUpgradePolicy.canUpgradeLevel(users.get(6))).isFalse();

    }

    @Test
    void upgradeLevel() {

        userLevelUpgradePolicy.upgradeLevel(users.get(1));
        assertThat(users.get(1).getLevel()).isEqualTo(Level.SILVER);

        userLevelUpgradePolicy.upgradeLevel(users.get(3));
        assertThat(users.get(3).getLevel()).isEqualTo(Level.GOLD);

        userLevelUpgradePolicy.upgradeLevel(users.get(5));
        assertThat(users.get(5).getLevel()).isEqualTo(Level.PLATINUM);
    }
}

 

3) UserServiceTest.java

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
class UserServiceTest {

    @Autowired
    UserService userService;

    @Autowired
    IUserDao userDao;

    @Autowired
    DefaultUserLevelUpgradePolicy defaultUserLevelUpgradePolicy;

    @Autowired
    EventUserLevelUpgradePolicy eventUserLevelUpgradePolicy;

    List<User> users; // 테스트 픽스처

    @BeforeEach
    void setUp(){
       users = Arrays.asList(
               new User("test1","테스터1","pw1", Level.BASIC, 49, 0),
               new User("test2","테스터2","pw2", Level.BASIC, 50, 0),
               new User("test3","테스터3","pw3", Level.SILVER, 60, 29),
               new User("test4","테스터4","pw4", Level.SILVER, 60, 30),
               new User("test5","테스터5","pw5", Level.GOLD, 100, 100),
               new User("test6","테스터6","pw6", Level.PLATINUM, 100, 100)
       );
    }

    @Test
    @DisplayName("업그레이드 레벨 테스트-DefaultUserLevelUpgradePolicy")
    void upgradeLevelWithDefaultUserLevelUpgradePolicy(){
        userService.setUserLevelUpgradePolicy(defaultUserLevelUpgradePolicy);
        userDao.deleteAll();
        users.forEach(user -> 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);
        checkLevelUpgraded(users.get(5),false);
    }

    @Test
    @DisplayName("업그레이드 레벨 테스트-EventUserLevelUpgradePolicy")
    void upgradeLevelWithEventUserLevelUpgradePolicy(){
        userService.setUserLevelUpgradePolicy(eventUserLevelUpgradePolicy);
        userDao.deleteAll();
        users.forEach(user -> userDao.add(user));

        userService.upgradeLevels();
        checkLevelUpgraded(users.get(0),true);
        checkLevelUpgraded(users.get(1),true);
        checkLevelUpgraded(users.get(2),true);
        checkLevelUpgraded(users.get(3),true);
        checkLevelUpgraded(users.get(4),true);
        checkLevelUpgraded(users.get(5),false);

    }

    @Test
    @DisplayName("레벨이 할당되지 않은 User 등록")
    void addWithNotAssignLevel(){
        userDao.deleteAll();

        User user = users.get(0);
        user.setLevel(null);

        userService.add(user);

        checkLevelUpgraded(userDao.get(user.getId()), false);
    }

    @Test
    @DisplayName("레벨이 할당된 User 등록")
    void addWithAssignLevel(){
        userDao.deleteAll();

        User user = users.get(4);
        userService.add(user);

        checkLevelUpgraded(userDao.get(user.getId()), false);
    }

    private void checkLevelUpgraded(User user, boolean upgraded){
        User userUpdate = userDao.get(user.getId());

        if(upgraded){
            assertThat(userUpdate.getLevel()).isEqualTo(user.getLevel().getNexeLevel());
        }else{
            assertThat(userUpdate.getLevel()).isEqualTo(user.getLevel());
        }
    }
}

4. 회고

 상수는 enum으로 관리하고, 비지니스 로직은 Service, 데이터 조작은 DAO, 각 객체가 가진 내부정보(멤버필드)의 수정은 스스로 처리하도록 구현했다. 이론적으로는 숙지하고 있던 내용이었지만 실무에서는 객체 스스로 처리하도록 하는 작업을 많이 생략했던 것 같다. 비지니스 로직에만 집중했었기 때문이었다. 회사의 업무 롤도 분명 큰 영향이 있었지만, OOP 대해 깊히있게 이해하려 노력하지 않았던게 더 큰것 같다. 이번 기회를 통해 객체 스스로 처리하도록 구현하는 부분에 더 신경쓸 수 있게 된 것 같다.

 스프링에 대해 어느정도 안다고 생각했지만, 모르는 부분도 너무 많다고 생각한다. 이를 바로잡기 위해 토비의 스프링을 공부하고 있는데, 공부하면 할수록 기초적이지만 중요한 내용들을 놓치고 있었구나 라는게 느껴진다.

반응형

+ Recent posts