반응형

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. 개요

 Redis를 활용하여 사전순으로 조회되는 자동완성 기능을 구현했으나 검색빈도에 따라 자동완성 리스트를 뿌려주는 것이 사용자 입장에서 유용할 것 같다는 생각이 들었다. 또한 사용자 검색한 새로운 키워드도 데이터 셋에 추가해야 트렌드에 맞는 자동완성 리스트를 제공할 수 있을 것 같아 이를 적용하는 작업을 진행하였다.

 


2. 분석

 현재 redis에 들어가 있는 자동완성 데이터는 Score가 모두 0인 Sorted Set 형식이다. 또한 사용자가 검색한 단어를 prefix로 갖고 있는 완성된 단어를 뿌려주기 위해 필자가 정한 규칙에 맞게 데이터가 들어가 있는 상황이다. 예를들어 '대', '대한', '대한민', '대한민국' 이라는 키워드 입력시 자동완성 리스트에 '대한민국' 이라는 단어가 나오도록 하기 위해 score를 모두 0으로하여  '대', '대한', '대한민', '대한민국', '대한민국*' 값을 sorted Set 형식의 데이터 셋에 저장시킨 상태이다.

 

 비지니스 로직은 다음과 아래와 같이 구현되어 있다.

 1) 검색어와 일치하는 단어의 index 조회(zrank 명령어 사용) 

 2) index 번째부터 index + 100번째까지의 데이터 조회(zrange 명령어 사용)

 3) '*' 문자를 포함하는 완전한 단어를 필터링

 4) 검색어의 prefix와 일치하는 완전한 단어를 필터링 및 limit

 

위에 대한 자세한 내용은 이전 포스팅에 기재되어있다.

https://tlatmsrud.tistory.com/106

 

[Redis] Redis를 활용한 자동완성 구현 / 자동완성 데이터 셋 만들기

1. 개요 Redis를 활용하여 자동완성 기능을 구현해보았다. 2. Redis를 선택한 이유 2.1. 속도 Redis는 인 메모리에 데이터를 저장하고 조회하기 때문에 디스크에 저장하는 DB보다 훨씬 빠르다. 속도가

tlatmsrud.tistory.com

 

 어쨌든 검색빈도 순으로 조회시키기 위해서는 반드시 검색횟수를 관리하는 데이터셋이 필요했다. 먼저 기존 데이터셋을 활용하는 쪽으로 시도하였으나 실패했다.

 


3. 기존 테이터 셋 활용

 기존 데이터 셋의 score를 활용할 경우 사용자가 단어를 입력 후 '검색' API를 호출했을 때 검색한 단어가 데이터 셋에 추가되어야 하고, score가 1이 올라가야 한다. 이때 두가지 케이스로 나눌 수 있는데, 검색한 단어에 대해서만 score를 올리는 케이스, 검색한 단어와 연관된 데이터들의 score를 올리는 케이스이다.

 

3.1. 검색한 단어에 대해서만 Score 증가

 전자의 경우 score가 올라가는 완성된 단어들은 데이터셋의 하단부에 위치하게 되고, 나머지 데이터들은 상단부에 위치하게 된다. 원래 나머지 데이터들은 자동완성 단어를 index 기반으로 빠르게 조회하기 위해서 사용했었으나, 자동완성 단어의 score가 올라가며 index 규칙이 깨지게 되었다.

 아까 말한대로 자동완성들은 하단부, 나머지 데이터는 상단부에 위치하게 되니 결국 index부터 끝까지 모든 값을 조회해야하는 상황이 발생했다. 지금은 데이터 셋이 적어서 큰 문제가 되지 않을테지만, 시간 복잡도에 따라 데이터 양이 증가할수록 속도가 느려질 것이다. (zrange의 시간복잡도는 O(log(n)+m)이다. m은 반환된 결과의 수이다.) 아래는 그 예이다.

 

AS-IS의 경우 '대'에 대한 zrange 시 index 기준 +4에 대한민국*이 위치한다. 즉, 가까운 곳에 자동완성 단어들이 위치하고 있기 때문에 굳이 데이터셋의 끝까지 조회할 필요가 없다. 필자의 경우 가중치를 두어 index ~ index+1000 까지만 조회하도록 했다.

index score value
n 0
n+1 0 대한
n+2 0 대한민
n+3 0 대한민국
n+4 0 대한민국*

 

TO-BE의 경우 '대'에 대한 zrange 시 index와 먼 곳에 자동완성 단어들이 위치한다. 데이터 셋의 끝까지 조회해야 할 필요성이 느껴진다.

index score value
n 0
n+1 0 대한
n+2 0 대한민
n+3 0 대한민국
... ... ..
m 1 대한민국*

 

3.1. 검색한 단어와 연관된 데이터들의 Score 증가

 후자의 경우는 자동완성 단어 누락을 야기할수 있다. '대만'이라는 데이터를 추가하여 예를 들어보겠다. 

index score value
n 0
n+1 0 대만
n+2 0 대만*
n+3 0 대한
n+4 0 대한민
n+5 0 대한민국
n+6 0 대한민국*

 

score가 0일때는 위와 같이 사전순으로 정렬된다. 사용자가 '대한민국'이라는 검색 API를 통해 검색하는 순간 관련된 데이터인 '대', '대한', '대한민', '대한민국', '대한민국*'의 score가 1씩 올라갈것이다. 그리고 데이터는 아래와 같이 조회된다.

 

index score value
n-2 0 대만
n-1 0 대만*
n 1
n+1 1 대한
n+2 1 대한민
n+3 1 대한민국
n+4 1 대한민국*

 

이때 '대'에 대한 자동완성 단어들은 index 기준 앞, 뒤에 위치해있다. 기존에는 자동완성 단어들을 찾기위해 한쪽 방향으로 조회했다면, 이제는 양쪽 방향으로 조회하며 찾아야한다.

 또한 새로운 단어들이 추가될 경우 score에 따라 데이터들이 뒤죽박죽 섞여버린다. 섞여버린 데이터들에서 자동완성 단어를 찾으려면 무조건 처음부터 끝까지 풀서치를 할수밖에 없는 상황이 되었다.

index score value
n-? 0 대만
n-? 0 대만*
n-? 100 필라델피아*
... ...  
n 500
... ...  
n+? 1000 대한민국*
... ...  
n+? 20000 삼성전자 주가*

 

결국 기존 데이터셋은 그대로 유지하고, 검색 횟수를 관리하는 새로운 데이터셋을 추가하기로 했다.

 


4. 새로운 데이터 셋 도입

 검색 단어들을 관리하는 새로운 데이터 셋을 만들었다. value는 검색어, score는 검색횟수이다. 기존 데이터셋은 자동완성 데이터를 조회하기 위해 기존과 동일하게 사용하고, 새로운 데이터셋은 기존 데이터셋에서 조회한 자동완성 단어에 대해 score(검색횟수)를 조회하는 용도로 사용했다. 이후 비지니스 로직을 통해 score 기준으로 내림차순 정렬처리 하였다.

자동완성 데이터 셋 검색 횟수 데이터셋
score value score value
0 0 대만
0 대만 0 대한민국
0 대만*    
0 대한    
0 대한민    
0 대한민국    
0 대한민국*    

 

검색 횟수 데이터셋이 추가됨에 따라 기존 로직에서 아래 빨간 부분에 대한 로직을 추가하였다.

 

 1) 검색어와 일치하는 단어의 index 조회(zrank 명령어 사용)

 2) index 번째부터 index + 100번째까지의 데이터 조회(zrange 명령어 사용)

 3) '*' 문자를 포함하는 완전한 단어를 필터링

 4) 검색어의 prefix와 일치하는 완전한 단어를 필터링 및 limit

 5) 필터링된 데이터의 score 조회 (zscore)

 6) score를 기준으로 정렬

 

* 참고로 zscore를 사용한 이유는 시간복잡도가 O(1)이기 때문이다. 시간이 지날수록 데이터 양이 많아지는 데이터 특성 상 처리 속도를 일정하게 유지하는 것이 효율적이라고 판단하여 이 방식을 채택했다.

 

이제 실제로 구현해보자.

 


5. 구현

5.1. AutocompleteController.kt

@RestController
@RequestMapping("/api/autocomplete")
class AutocompleteController (
    private val autocompleteService : AutocompleteService
){

    @GetMapping("/{searchWord}")
    @ResponseBody
    fun getAutocompleteList(@PathVariable searchWord : String) : ResponseEntity<AutocompleteResponse> {

        return ResponseEntity.ok(autocompleteService.getAutocomplete(searchWord))
    }
}

 기존과 동일하다.

 

5.2. AutocompleteService.kt

@Service
class AutocompleteService (
    private val redisTemplate : RedisTemplate<String, String>,
    @Value("\${autocomplete.limit}") private val limit: Long,
    @Value("\${autocomplete.suffix}") private val suffix : String,
    @Value("\${autocomplete.key}") private val key : String,
    @Value("\${autocomplete.score-key}") private val scoreKey : String
){
    fun getAutocomplete(searchWord : String) : AutocompleteResponse {

        val autocompleteList = getAutoCompleteListFromRedis(searchWord)
        return sortAutocompleteListByScore(autocompleteList)
    }

    fun addAutocomplete(searchWord : String ){

        val zSetOperations = redisTemplate.opsForZSet()
        zSetOperations.incrementScore(scoreKey, searchWord, 1.0)

        zSetOperations.score(key, searchWord)?:let {
            for(i in 1..searchWord.length){
                zSetOperations.add(key, searchWord.substring(0,i),0.0)
            }
            zSetOperations.add(key, searchWord+suffix,0.0)
        }

    }

    fun getAutoCompleteListFromRedis(searchWord : String) : List<String> {

        val zSetOperations = redisTemplate.opsForZSet()
        var autocompleteList = emptyList<String>()

        zSetOperations.rank(key, searchWord)?.let {
            val rangeList = zSetOperations.range(key, it, it + 1000) as Set<String> // 가중치 1000
            autocompleteList = rangeList.stream()
                .filter { value -> value.endsWith(suffix) && value.startsWith(searchWord) }
                .map { value -> StringUtils.removeEnd(value, suffix) }
                .limit(limit)
                .toList()
        }

        return autocompleteList
    }

    fun sortAutocompleteListByScore(autocompleteList : List<String>) : AutocompleteResponse{
        val zSetOperations = redisTemplate.opsForZSet()

        val list = arrayListOf<AutocompleteResponse.Data>()
        autocompleteList.forEach{word ->
                zSetOperations.score(scoreKey, word)?.let {
                    list.add(AutocompleteResponse.Data(word, it))
                }
        }
        list.sortByDescending { it.score }
        return AutocompleteResponse(list)

    }
}

 

 

1) getAutoCompleteListFromRedis 메서드를 통해 Redis의 자동완성 데이터셋에서 자동완성 데이터를 가져온다. 가중치는 1000, limit는 10으로 설정하였다. 

 

2) sortAutocompleteListByScore 메서드를 통해 검색횟수 데이터 셋에 값이 있는지 확인한다. 값이 있을 경우 score 가져와 list에 추가하고, sortByDescending 메서드를 사용해 score 기준으로 내림차순 정렬한다.

 

3) addAutocomplete는 검색 로직에서 사용하는 메서드로, 자동완성 데이터셋에 검색어가 없을 경우 규칙에 맞게 추가하고, 검색횟수 데이터 셋에 score를 추가 및 증가시킨다.

 

 * 참고로 검색횟수 데이터 셋 score를 증가시킬 때 사용한 incrementScore는 데이터 셋에 값이 없을경우 자동으로 값을 추가해준다.

 

 

5.3. SearchService.kt

    fun searchProductList(searchWord: String, pageable: Pageable): ProductListResponse? {
        autocompleteService.addAutocomplete(searchWord)
        return productRepository.selectProductListBySearchWord(searchWord,pageable)
    }

 검색관련 비지니스 로직이다.(자동완성 로직이 아니다.) 여기서 autocompleteService를 DI받아 addAutocomplete를 호출하고 있다. 

 

 

5.4. AutocompleteResponse.kt

data class AutocompleteResponse(
    val list : List<Data>
){
    data class Data(
        val value: String,
        val score: Double
    )
}

 자동완성 응답 DTO 클래스이다.

 


6. 테스트

 게임 기종 관련하여 자동완성 데이터셋과 검색횟수 데이터셋을 생성한 후 테스트를 진행하였다.

 

6.1. 자동완성 리스트 조회

 '닌'이라는 단어를 입력했을 때 조회되는 자동완성 리스트들이다. value에는 자동완성 단어, score에는 검색횟수가 조회되고 있다.

'닌'에 대한 자동완성 리스트 #1

 

6.2. 검색 후 자동완성 리스트 조회

 이제 제일 하단에 조회되는 '닌텐도 DS'라는 단어를 검색 API를 통해 검색한 후 '닌'에 대한 자동완성 리스트를 다시 조회해보자. 아래와 같이 닌텐도 DS라는 단어가 최상단에 조회됨을 확인할 수 있다.

'닌'에 대한 자동완성 리스트 #2

 

6.3. 새로운 단어 검색 후 조회

 검색 API를 통해 '닌텐도 new 3DS'라는 새로운 단어를 두번 검색 후 다시 조회해보자. 아래와 같이 '닌텐도 new 3DS' 새로 추가됨과 동시에 최상단에 위치해있는 걸 확인할 수 있다.

'닌'에 대한 자동완성 리스트 #3

 


7. 회고

 기존 데이터셋을 활용하는게 메모리적와 속도면에서 훨씬 효율적일 것 같아 여러 시도를 해보았지만, 결국 새로운 데이터셋을 추가하는 쪽으로 구현하게 되었다.

 새로운 데이터 셋 추가를 꺼려했던 이유는 속도 때문이었다. 처음엔 메모리도 신경쓰였으나 이 경우 기존 데이터셋보다 10분의 1도 안될 것이었기때문에 큰 걱정은 되지 않았다. 하지만 Redis를 한번 더 거쳐야 하고, 거기서 많은 자원을 사용하는 로직이 포함될 경우 처리 속도가 너무 느려지지 않을까하는 걱정이 앞섰다.

 때문에 redis 문법에 대한 처리 속도와 redis 라이브러리에서 제공하는 여러 메서드들을 하나하나 찾아가며 어떤게 더 효율적일지를 고민하게 되었고, 값에 대한 score를 조회한 후 비지니스 로직에서 정렬하는 방식을 채택하게 되었다.

 score 조회 명령어의 시간복잡도와, 비지니스 로직에서 정렬하는 데이터의 개수(10개)를 고려했을 때 처리속도에 큰 영향을 끼칠만큼의 복잡도가 아니라고 생각했기 때문이다.

 글로 남기지 않은 많은 시행착오가 있었지만 결국 나름 괜찮은(?) 자동완성 API를 구현하게 된것 같다. 혹시 포스팅 관련하여 수정할 내용이나 피드백이 있다면 꼭! 꼭! 알려주시길 바란다 :)

 


8. 참고

1) RedisTemplate 공식문서 - https://docs.spring.io/spring-data/redis/docs/current/api/org/springframework/data/redis/core/RedisTemplate.html

2) Redis 명령어 - http://redisgate.kr/redis/command/zrange.php

반응형
반응형

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. 개요

 Redis를 활용하여 자동완성 기능을 구현해보았다.

네이버에서 사용하는 자동완성

 


2. Redis를 선택한 이유

2.1. 속도

 Redis는 인 메모리에 데이터를 저장하고 조회하기 때문에 디스크에 저장하는 DB보다 훨씬 빠르다. 속도가 중요한 이유는 사용자가 단어를 한글자씩 입력할때마다 자동완성 값을 빠르게 뿌려줘야 하기 때문이다.

 네이버에서 '가, 나, 다, 라' 단어를 입력하면 아래와 같이 '가, 가나, 가나다, 가나다라'에 대한 자동완성 조회 API로 통신한다. 프론트에 값이 미리 저장되어 있는 것처럼 즉시적으로 나오는데 서버 응답 값이다. 속도가 빠를수록 자동완성 리스트를 더 빨리 제공할 수 있으며, 조회의 성격이 강한 데이터이므로 Redis를 선택했다.

 여담으로 네이버의 경우 입력한 검색어를 한번 더 입력할 경우 서버와 통신하지 않고 자동응답 값을 가져오고 있다. 서버에서 값을 조회했을 때 해당 검색어와 응답 데이터셋을 프론트에 저장해두고 가져다 쓰는 것 같다.

네이버 자동완성 기능

 

2.2. Sorted Set

 Redis는 데이터를 저장하기 위한 다양한 자료구조를 제공한다. 그 중 Sorted Set은 문자열을 Score와 함께 관리하고, Score로 정렬되는 자료구조인데, 동일한 점수를 갖는 경우 '사전순'으로 정렬한다. 

Redis 자료구조 - https://meetup.nhncloud.com/posts/224

Redis 정렬 세트는 연관된 점수로 정렬된 고유한 문자열(구성원)의 모음입니다. 둘 이상의 문자열이 동일한 점수를 갖는 경우 문자열은 사전순으로 정렬됩니다. - https://redis.io/docs/data-types/sorted-sets/

 일반적으로 검색어와 prefix가 일치하는 단어들을 자동완성 리스트로 뿌려야하는데, 사전순으로 정렬이 되어있다면 prefix가 일치하는 단어들은 검색어보다 뒤에 위치한다. 즉, 검색어의 index를 안다면 prefix가 일치하는 리스트를 뽑아낼 수 있다. 그리고 이 리스트가 자동완성 단어가 된다. 이러한 성격을 활용하면 어렵지 않게 자동완성 기능을 구현할 수 있다.

 


3. 관련 명령어

 Sorted Set을 사용하기 위한 주요 Redis 명령어들이다. 이해를 위해 아래 명령어는 숙지하는 게 좋다.

 

3.1. ZADD

 데이터 셋에 데이터를 Score와 함께 추가한다.

 [ZADD key score value]

 요청 > ZADD mydataset 1 한국

 요청 > ZADD mydataset 2 미국

 요청 > ZADD mydataset 3 러시아

 요청 > ZADD mydataset 4 프랑스

 요청 > ZADD mydataset 4 북한

 요청 > ZADD mydataset 4 가나

 

3.2. ZRANGE

  주어진 범위 내에 데이터 셋을 반환한다. Score가 적은 것부터 조회하며, 동일한 Score를 가진 데이터가 여러개 있을 경우 사전순으로 조회된다.

 [ZRANGE key startIndex endIndex]

 요청 > ZRANGE mydataset 0 -1

 응답 >

 1) 한국

 2) 미국

 3) 러시아

 4) 가나

 5) 북한

 6) 프랑스

 

3.3. ZRANK

 정렬된 기준이 오름차순이라고 가정하고 검색한 데이터의 index를 반환한다.

 [ZRANK key value]

 요청 > ZRANK mydataset 한국

 응답 > 0

 요청 > ZRANK mydataset 미국

 응답 > 1

 


4. 분석

 이제 Reids의 Sorted Set을 활용해 어떻게 구현할지 분석해보자.

 

4.1. 자동완성 단어 조회 매커니즘

 기본적인 매커니즘은 어떤 값을 조회하면, 그 값을 prefix로 갖는 단어들을 조회하는 것이다. 

 '안' 이라는 값을 입력하면 '안경', '안경점'을 포함하는 리스트가 조회되어야 한다.

 '안경' 이라는 값을 입력하면 '안경', '안경점'을 포함하는 리스트가 조회되어야 한다.

 Sorted Set을 활용하면 이러한 구조를 쉽게 구현할 수 있다. 먼저 zadd를 통해 데이터 셋을 추가해주는데, '안경점' 이라는 문자를 추가하고자 할 경우, 이를 구성하는 [안, 안경] 이라는 문자열도 데이터 셋에 추가해줘야한다. 

 

요청 > zadd mylist 0 안경점

요청 > zadd mylist 0 안경

요청 > zadd mylist 0 안

 

안경점 이라는 자동완성 단어를 추가하기 위해 Sorted Set에 0 Score로 '안경점', '안경', '안' 을 zadd 하였다. range mylist 0 -1로 모든 리스트를 조회하면 아래와 같이 사전순으로 '안', '안경', '안경점'이 조회된다.

 

요청 > zrange mylist 0 -1

응답 >

1) 안

2) 안경

3) 안경점

 

 여기서 앞서 언급했던 매커니즘을 예시에 적용하면 ['안'을 조회하면, '안'을 prefix로 갖는 단어들이 조회되어야 한다.]이다. 그런데 사전순으로 정렬되어 있다보니 '안'을 prefix로 갖는 단어들은 '안'의 index를 포함하여 아래 위치한 값들이 된다. 마찬가지로 '안경'을 조회하면 '안경'을 prefix로 갖는 단어들은 '안경'이 갖는 index를 포함하여 아래 위치한 값들이다. 결국 검색어에 대한 index를 찾는다면 자동완성 단어를 추출할 수 있다. 이 index는 zrank 명령어로 찾으면 된다.

 정리하면 zadd를 통해 자동완성 단어에 대한 데이터 셋을 만들고,  zrank를 사용해 검색어에 대한 index를 찾고, zrange를 통해 index 부터 조회하여 연관 단어를 찾는 것이다.

 

4.2. 완전한 단어

 '플레이스테이션' 이라는 단어로 예를 더 들어보겠다. 이 단어를 자동완성 단어로 사용하기  '플', '플레', '플레이' ... '플레이스테이션' 데이터들을 추가했다.

 

요청 > zadd mylist 0 플

요청 > zadd mylist 0 플레

요청 > zadd mylist 0 플레이

요청 > zadd mylist 0 플레이스

요청 > zadd mylist 0 플레이스테

요청 > zadd mylist 0 플레이스테이

요청 > zadd mylist 0 플레이스테이션

 

그리고 '플레이' 라는 단어에 입력했다고 가정하고 index를 구했다.

 

요청 > zrank mylist 플레이

응답 > 2

 

마지막으로 prefix를 가진 값들을 조회하기 index 부터 값을 조회했다.

 

요청 > zrange mylist 2 - 0

응답 >

1) 플레이

2) 플레이스

3) 플레이스테

4) 플레이스테이

5) 플레이스테이션

 

여기서 플레이스, 플레이스테, 플레이스테이와 같은 완전한 단어가 아니다. 이러한 값들은 자동완성 단어에 적합하지 않다. 하지만 없어서는 안된다. index를 찾아야만 완전한 단어를 검색할 수 있기 때문이다.

 결국, 완전한 단어를 나타내는 데이터와 그 단어를 검색하기 위한 데이터를 구분해야한다. 이는 완전한 단어의 suffix에 '*'와 같은 구분자를 붙어주면 된다.

 

요청 > zadd mylist 0 플레이스테이션*

 

이 상태에서 다시 zrange를 하게 된다면 아래와 같이 값들이 조회될것이다.

 

요청 > zrange mylist 2 - 0

응답 >

1) 플레이

2) 플레이스

3) 플레이스테

4) 플레이스테이

5) 플레이스테이션

6) 플레이스테이션*

 

 여기서 자동완성에 쓸 데이터는 완전한 단어인 '*'가 붙은 문자열들만 추출한 후 사용하면 된다. 이렇게 될 경우 플레이, 플레이스, 플레이스테이, 플레이스테이션을 검색해도 자동완성 리스트에 조회되는 결과는 플레이스테이션이라는 완전한 단어만이 조회될 것이다.

 '*' 문자를 포함하는 데이터만을 추출하는 작업은 redis에서 지원하는 문법이 없는 관계로 비지니스 로직에서 처리하면 된다.

 


5. 데이터 셋 만들기

 결국 위 내용에 따라 자동완성 데이터 셋들을 만들어야 한다. '플레이스테이션'이라는 단어에 대해서는 8개의 데이터를 redis에 넣어줘야한다. 엥? 필자는 많은 단어들을 넣어야하는데... 생각해보니 너무 오래걸릴 것 같아서 이 부분은 넘어가도록 하겠다.

 

 는 대학생 과제 제출 시 마인드였고, 미래를 생각했을때 데이터 셋을 자동으로 생성해주는 뭔가가 있지 않을까 해서 구글링을 하였다. 마침 AWS 공식 블로그에서 Redis 자동완성 관련 내용을 다룬 게시글이 있었고 파이썬으로 데이터 셋을 만들어 redis에 넣는 코드가 있었다.

 코드 내용을 분석하여 나에게 맞게 수정한 후 웹에서 파이썬 코드를 컴파일할 수 있는 https://replit.com/ 사이트에서 코드를 실행하였다. 여기 접속해서 아래 아래 코드를 실행시키면 Redis 서버로 위와 같은 데이터셋을 추가할 수 있다.

더보기

Redis Connection 에러 발생시!!

 웹사이트에서 실행되는거라 그런지 host에 로컬 IP를 입력할 경우 redis connection refused 에러가 발생했다. 필자의 경우 포트포워딩 설정 후 host에 외부 IP를 입력해주니 connection 문제가 해결되었다.

 

https://replit.com/ 

 

Replit: the collaborative browser based IDE

Run code live in your browser. Write and run code in 50+ languages online with Replit, a powerful IDE, compiler, & interpreter.

replit.com

 

아래 코드에서 host, port을 입력하고 완전한 단어들이 들어있는 autocomplete.txt 파일을 생성해준다. text 파일은 왼쪽 파이썬 코드와 같은 레벨에 생성해주면 된다.

예제 코드 작성 방법

#-*- coding:utf-8 -*-
import redis

r = redis.StrictRedis(host='host 입력', port=port입력, db=0)

f = open("autocomplete.txt","rt", encoding="utf-8")
for line in f:
  n = line.strip()
  for l in range(len(n)+1):
    prefix = n[0:l]
    print(prefix)
    r.zadd('autocomplete2',{prefix : 0})
  r.zadd('autocomplete2',{n+"*" : 0})
  print(n+"*")
else:
  exit

 

여기서 또 여담하나 풀도록...(엣헴)

위 코드를 실행하고 redis에 들어간 데이터를 redis-cli로 확인해봤더니 아래와 같이 한글이 깨져서 들어갔다.

 

뀨?

 닌텐도와 닌텐도 DS라는 단어였는데 영어는 잘들어가고 한글은 깨져서 들어갔다. 이왕 사용할거 코드가 간단하기도 하고 스프링 기반으로 내가 직접 만들어보자라는 생각에 파이썬 코드를 분석하여 스프링 기반으로 만들었다.

 나중에 알고보니 저렇게 깨지는건 필자가 언젠가 Redis 설정을 건들면서 발생한 문제로 한글이 깨져 보였던 것이었고, 재설치하니 잘 조회되더라. 참고로 저렇게 보여도 잘 들어간게 맞았습니다... (네?)

 그렇다고 뻘짓을 한건 절대 아니다. 웹 사이트에서 실행 시 redis로 데이터가 들어가는 속도가 많이 느렸기 때문이다. git 잔디도 심었으니 오히려좋아... 

 


6. 구현

 코틀린 기반으로 로직을 구현했다. 설정 관련 코드와 주요 비지니스 로직이 담긴 Service 코드만 설명하겠다.

 

6.1. RedisConfig.kt

@Configuration
class RedisConfig(
    @Value("\${spring.redis.port}") private val port: Int,
    @Value("\${spring.redis.host}") private val host: String,
) {

    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory? {
        return LettuceConnectionFactory(host, port)
    }
    @Bean
    fun redisTemplate(): RedisTemplate<String, String> {
        val template = RedisTemplate<String, String>()
        redisConnectionFactory()?.let { template.setConnectionFactory(it) }
        template.keySerializer = StringRedisSerializer()
        template.valueSerializer = StringRedisSerializer()
        return template
    }
}

 keySerializer와 valueSerializer를 모두 StringRedisSerializer로 설정하였다. 객체 형태의 value 값을 관리할 때에는 valueSerializer를 GenericJackson2JsonRedisSerializer 형식으로 설정하는데, 자동완성에 적용하니 큰따옴표(")로 인한 이슈가 발생했다.

 

데이터 셋

'닌텐도', '닌텐도 DS' 문자로 데이터 셋을 만들면 위와 같이 문자열이 생성되는데 GenericJackson2JsonRedisSerializer 이 설정된 상태로 Redis에 데이터를 넣게 된다면 문자열마다 큰따옴표가 들어가게 된다. 아래와 같이 말이다.

 

GenericJackson2JsonRedisSerializer 사용 시 조회 데이터

 

의도했던 정렬 순서로

[닌, 닌텐, 닌텐도, 닌텐도*, 닌텐도(공백), 닌텐도 D, 닌텐도 DS, 닌텐도 DS*] 가 되어야 하는데 저 큰따옴표가 들어가는 바람에 사전순으로 정렬할 시 (공백)문자보다 큰따옴표를 나타내는 \" 문자가 뒤로 밀려

[닌, 닌텐, 닌텐도(공백), 닌텐도 D, 닌텐도 DS, 닌텐도 DS*, 닌텐도, 닌텐도*] 순으로 정렬이 되버렸다.

 이 상태에서 '닌텐도 DS'를 검색한다면 '닌텐도 DS'와 '닌텐도' 가 조회되게 된다. 이 이슈로 인해 영향도를 분석 후 valueSerializer를  GenericJackson2JsonRedisSerializer 에서 StringRedisSerializer로 변경하였고, 큰따옴표로 인한 문제는 해결되었다.

 

StringRedisSerializer 사용 시 조회 데이터

 

 그런데 또다른 문제가 발생했다. 이번에는 완전한 단어를 나타내기 위해 넣었던 '*' 로 인해 발생했다. 현재 정렬되는 데이터 순서는 [닌, 닌텐, 닌텐도, 닌텐도(공백), 닌텐도 D, 닌텐도 DS, 닌텐도 DS*, 닌텐도*] 였기 때문에 '닌텐도 DS'를 검색한다면 위와 마찬가지로 '닌텐도 DS'와 '닌텐도'가 여전히 조회된다.

 

이에 대해 총 두가지 방법을 생각해냈다. 첫번째 방법은 검색어가 redis에서 추출한 데이터의 시작부분에 포함되어 있는지를 비지니스 로직에서 확인하는 것이고, 두번째 방법은 완전한 단어를 나타내는 문자 앞에 공백을 추가하여 '*'에서 ' *'로 변경하는 방법이었다.

 

 결국은 첫번째 방법을 선택했는데, 그 이유는 이 과정이 필수적으로 들어가야 했기 때문이다. 플레이스테이션1, 플레이스테이션2 에대한 데이터 셋이 있을 때 '플레이스테이션'을 검색할 경우 [플레이스테이션1, 플레이스테이션2] 가 조회되어 정상으로 보이지만, '플레이스테이션1'을 검색할 경우 [플레이스테이션1] 뿐 아니라 [플레이스테이션 2]도 같이 조회되게 된다. 이 이유는 아래와 같이 플레이스테이션1보다 플레이스테이션2가 가나다 순으로 더 뒤에 위치하기 때문이다. 

 

1)플

2) 플레

3) 플레이

...

x) 플레이스테이션1*

x+1) 플레이스테이션2

x+2) 플레이스테이션2*

 


6.2. AutocompleteService.kt

@Service
class AutocompleteService (
    private val redisTemplate : RedisTemplate<String, String>,
    @Value("\${autocomplete.limit}") private val limit: Long,
    @Value("\${autocomplete.suffix}") private val suffix : String,
    @Value("\${autocomplete.key}") private val key : String
){
    fun getAutocomplete(searchWord : String) : AutocompleteResponse {
        val zSetOperations = redisTemplate.opsForZSet()
        var autoCompleteList  = emptyList<String>()

        zSetOperations.rank(key, searchWord)?.let {

            val rangeList = zSetOperations.range(key, it, it+1000) as Set<String>

            autoCompleteList =  rangeList.stream()
                .filter { value -> value.endsWith(suffix) && value.startWith(searchWord)}
                .map { value -> StringUtils.removeEnd(value,suffix) }
                .limit(limit)
                .toList()
        }
        return AutocompleteResponse(autoCompleteList)
    }
}

sortedSet에 대한 작업을 수행할 수 있는 zSetOperation 객체를 생성하고, 위 매커니즘에서 언급했던 작업들을 구현하였다.

 

1) zSetOperations.rank(key, searchWord)?.ley{} : zrank를 명령어를 통해 검색어(searchWord) 데이터의 index를 구한다.

 

2) zSetOperations.range(key, it, it+1000) : zrange 명령어를 통해 index ~ index+1000 까지의 데이터를 조회한다. 사전순으로 정렬되어 있으니 index 와 가까운 위치에 자동완성 데이터가 존재할 것이기 때문이다. 이에 대한 가중치를 1000으로 설정하였기에 it+1000까지 조회하였다.

 

3) filter{value -> value.endsWith(suffix) && value.startWith(searchWord)} : 조회된 문자열 중 마지막이 suffix(=='*')로 끝나고,  검색어로 시작하는값들을 필터링한다.

 

4) map{ value -> StringUtils.removeEnd(value,suffix) } : 필터링 된 데이터들에 포함된 suffix를 제거한다.

 

5) limit(limit).toString() : 최대 limit개로 제한하여 리스트로 만든다.

 


7. 테스트

 원하는 값들이 정상적으로 나옴을 확인할 수 있다. 지금은 URL에 일일이 입력하여 처리하고 있지만, 프론트 단에서는 검색란에 값을 입력하는 순간 위 API를 태우도록 구현하면 된다.

테스트


8. 회고

 Sorted Set의 자료구조와 Redis 명령어들을 이해하기 위해 공식문서 위주로 보며 실습을 진행하였다. 어느정도 정리가 되니 어떻게 구현 방법을 계획했고, 실행에 옮겼다. 물론 이 과정에서 value 직렬화나, Redis 한글깨짐, 의도하지 않은 정렬 등 여러 이슈들을 마주했지만 그것들이 발생한 원인을 분석하고 해결해나가면서 배운 것들이 매우 의미있다고 생각한다.

 지금은 정렬된 순서 그대로 자동완성 단어들을 뽑아내고 있지만, 단어의 검색 량에 따라 조회되는 우선순위를 달리하여 자동완성 단어들을 뽑아낼 수 있도록 수정해도 좋을 것 같다.

 


9. 참고

Redis Sorted Set - https://redis.io/docs/data-types/sorted-sets/

Redis Autocomplete - https://aws.amazon.com/ko/blogs/database/creating-a-simple-autocompletion-service-with-redis-part-one-of-two/

Redis Command - http://redisgate.kr/redis/command/zrange.php

 

반응형
반응형

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 대해 깊히있게 이해하려 노력하지 않았던게 더 큰것 같다. 이번 기회를 통해 객체 스스로 처리하도록 구현하는 부분에 더 신경쓸 수 있게 된 것 같다.

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

반응형
반응형

1. 개요

 국토 교통부에서 제공하는 법정동 코드를 다운받아 DB 테이블에 밀어 넣고, JPA를 통해 주소를 검색하는 API를 구현하였다. 하지만 데이터의 양이 많아 응답까지 1초 ~ 3초정도가 소요되는 것을 보고 Redis의 캐싱 기능을 도입하게 되었다. 그 과정을 정리한다.

 

 


2. Cache 설정

2.1. RedisCacheConfig.kt

@Configuration
@EnableCaching
class RedisCacheConfig {

    @Bean
    fun redisCacheManager(cf: RedisConnectionFactory?): CacheManager? {
        val redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    GenericJackson2JsonRedisSerializer()
                )
            )
            .entryTtl(Duration.ofDays(1))
        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf!!)
            .cacheDefaults(redisCacheConfiguration).build()
    }
}

 

 TTL은 하루로 설정하였고, 직렬화 방식은 key는 String, value는 GenericJackson2JsonRedisSerializer를 사용했다. 이 형식을 사용한 이유는 캐싱할 value 값이 List<Object> 형태였기 때문이다.

 

2.2. Applicatoin.kt

@SpringBootApplication
@EnableCaching
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

 캐시를 사용하기 위해 Application 실행파일에 @EnableCaching을 추가하였다.

 

2.3. Service.kt

@Service
class AddressService (
    private val addressRepository: AddressRepository
){

    @Cacheable(value = ["Address"], key = "#searchWord", cacheManager = "redisCacheManager")
    fun searchAddress(searchWord : String) : List<AddressResponse> {
        return addressRepository.findBySearchWordLike(searchWord)
    }

}

 캐시를 적용하고자 하는 메서드에 @Cacheable 을 설정한다. [value]는 redis에 저장되는 Key의 prefix, [key]는 suffix 이다. 만약 동일한 키를 가진 캐싱 데이터가 있을 경우 cache hit가 발생하여 캐싱된 데이터를 조회하고, 없을 경우 cache miss가 발생하여 DB에서 데이터를 조회한 후 데이터를 캐싱할 것이다.

 cacheManage에는 RedisCacheConfig 에서 생성한 Bean 이름을 넣어준다.

 

2.4. Controller.kt

@Controller
@RequestMapping("/api/address")
class AddressController (
  private val addressService : AddressService
) {
...
    @GetMapping("/search")
    fun searchAddress(@RequestParam("searchWord") searchWord : String) : ResponseEntity<List<AddressResponse>> {

        val list = addressService.searchAddress(searchWord)
        return ResponseEntity.ok(list)
    }
...
}

 Controller에서 캐싱 메서드를 호출한다.


3. 테스트

3.1. 최초 검색

 최초 검색시 cache miss가 발생함에 따라 DB 조회 및 redis 에 데이터를 캐싱하는 과정을 거치게 된다. IDE에 찍힌 로그를 보면 JPA 쿼리 결과가 조회되는데, 이는 DB를 조회하여 데이터를 가져왔다는 사실을 알수있다.

 캐싱된 데이터는 redis-cli 를 실행 후 keys, get 명령어를 통해 확인할 수 있다. 시간은 1400ms가 소요되었다.

postman 테스트 결과 #1
JPA 쿼리 실행 결과 #1

 

3.2. 두번째 요청

postman 테스트 결과 #2

  동일한 값으로 요청하니 cache hit가 발생하여 DB를 조회하지 않고 캐싱된 데이터를 조회하고 있다. DB를 조회했다면 JPA 쿼리 결과가 콘솔에 찍힐테지만, 아무 로그도 찍히지 않고 있다. 시간은 72ms가 소요되었다.

 

3.3. 새로운 키워드로 요청

 캐싱된 값이 없으니 cache miss가 발생하고 3.1. 최초검색과 비슷한 시간이 소요됨을 확인하였다.


4. 문제

4.1. 건당 Redis 메모리 사용량

redis-cli 의 info memory 명령어를 통해 redis 메모리 사용량을 확인할 수 있다. 확인해보니 시, 도 별로 검색할 경우 한 건당 메모리가 약 1M 정도 사용되었다. 결코 적은 양이 아니다. 만약 시, 도별 다른 키워드로 검색을 한다면 cache miss가 발생하여 데이터를 캐싱할 것이고, 1M 정도가 추가로 사용될 것이었다.

 TTL을 하루로 설정하였기에 RAM 용량이 8GB라면 각기 다른 키워드로 8000번 호출 시 redis 서버가 다운될 가능성이 있다.

 

4.2. 주소 검색의 특성

주소 검색 특성상 많은 사람들이 똑같은 키워드보다는 본인이 사는 동이나, 지번으로 검색할 확률이 높다. 새로운 키워드가 들어올 확률이 높다는 것이다. cache miss가 빈번하게 발생할 것이고, 응답 시간은 DB를 단독으로 사용하는 것보다 느린 케이스도 빈번할 것이다. redis 메모리 사용량도 빠르게 늘어날 것이다.

 


5. 개선

 주소 검색 시 선 작업으로 모든 주소를 조회하는 로직을 추가하였다. 그 후 조회한 리스트에서 검색어에 대한 주소 값을 추출하는 방식을 채택했다.

 캐싱은 모든 주소를 조회하는 부분에 적용하였다. cache hit시 리스트에서 필터링하는 시간과 비용만 소비하면 된다. 물론, 첫번째 방식 사용 시보다 응답 속도가 느린 케이스도 있다. TTL이 만료되는 24시간 후 cache miss가 발생할 때 데이터를 가져올때나 동일 키워드로 여러번 검색할때이다. 일단 개선 로직 구현 후 이에 대한 트레이드 오프를 분석할 예정이다.

 

5.1. 개선 AddressController.kt

...
    @GetMapping("/search")
    fun searchAddress(@MemberAuthentication authenticationAttributes: AuthenticationAttributes
                      , @RequestParam("searchWord") searchWord : String) : ResponseEntity<List<AddressResponse>> {
        val allAddressList = addressService.searchAllAddress()
        val findAddressList = addressService.searchAddress(allAddressList, searchWord)

        return ResponseEntity.ok(findAddressList)
    }
...

 addressService.searchAllAddress() 메서드를 통해 캐싱되어있는 모든 주소리스트를 조회하고, searchAddress() 메서드를 통해 키워드를 포함하는 요소를 찾아 리턴한다.

 searchAddress() 메서드 안에서 searchAllAddress()를 호출할 수 도 있지만, AOP를 사용하는 캐싱 메서드의 특성 상 Self-invocation 이슈가 있어 Controller에서 따로 호출하였다.

 

더보기

* Self-invocation 으로 인한 이슈

 AOP 기반으로 호출되는 캐싱 메서드의 특성 상 같은 클래스 내 위치한 특정 메서드에 의해 캐싱 메서드가 호출될 경우 캐싱 기능이 동작하지 않는다. 이에따라 컨트롤러에서 캐싱 메서드를 호출하여 캐싱 처리하고, 응답받은 값을 통해 서치하는 메서드를 호출하는 방식을 채택하였다.

 

5.2. 개선 AddressService.kt

@Service
class AddressService (
    private val addressRepository: AddressRepository
){
    ...

    @Cacheable(value = ["AllAddress"], key = "", cacheManager = "redisCacheManager")
    fun searchAllAddress() : List<AddressResponse> {
        val list = addressRepository.findAll()
        return list.stream()
            .map { e -> AddressResponse(e.id, e.addr) }
            .collect(Collectors.toList())

    }

    fun searchAddress(list : List<AddressResponse>, searchWord: String): List<AddressResponse> {
        return list.stream()
            .filter { address -> address.name.contains(searchWord) }
            .collect(Collectors.toList())
    }
}

 searchAllAddress() 메서드는 DB에서 모든 주소 값을 읽어와 List<AddressResponse> 형태로 리턴하며 캐싱한다. cache miss가 발생 시 시간이 다소 소요된다. searchAddress는 list에 대해 필터링하는 메서드이다.


6. 개선 테스트

 

6.1. 최초 검색

 1차 테스트와 동일한 검색어로 검색한 결과 cache miss 발생 시 응답속도가 1400 ms 에서 2170 ms로 0.67초 느려진 것을 확인하였다. 모든 주소 값을 읽고, 캐싱하는 부분으로 인한 시간이라 생각한다.

개선 로직에 대한 postman 테스트 결과 #1

 

6.2. 두번째 검색

 동일한 키워드로 두번째 검색을 하였다. 전자의 경우 '시'라는 키워드에 대한 결과가 미리 캐싱되어있었기에 소요되는 시간이 조금 더 걸릴것으로 예상했으나, 72ms에서 92ms로 생각보다 시간 차이가 얼마 나지 않음을 알 수 있었다. 몇번 더 테스트해봐도 100ms 안밖이었다.

 cache miss가 발생하지 않았기에 사용되는 메모리는 증가하지 않았다. 참고로 약 9MB 정도를 사용중이다. 이제 다른 키워드로 검색해보자.

개선 로직에 대한 postman 테스트 결과 #2
Redis 메모리 사용량 #1

6.3. 새로운 키워드로 검색

 서울, 강원 등 여러 키워드로 검색해보았다. 그 결과 걸린 시간은 모두 100ms 안팎임을 확인하였다. 메모리 사용량도 변함이 없다.

개선 로직에 대한 postman 테스트 결과 #3
개선 로직에 대한 postman 테스트 결과 #4
Redis 메모리 사용량 #2


7. 장단점

 개선 방식으로의 변경을 통해 확인한 장단점을 정리해보았고, 트레이드 오프를 고려했을 때 개선된 방식이 훨씬 더 효율적이라는 결론을 내렸다.

 

* 장점

 1. Redis 메모리 부족 위험에 대해 완전히 벗어날 수 있다.

 2. 새로운 키워드로 검색해도 cache miss가 발생하지 않아 속도가 빠르다. (1400 ms > 100ms로 개선)

 3. 동일한 키워드로 검색해도 이전 방식과 속도차이가 거의 나지 않는다. (약 20ms 차이)

 

* 단점

 1. cache miss 발생 시 이전보다 더 많은 시간이 소요된다. (1400 ms > 2170 ms 로 증가)

 2. 데이터 정합성 문제 발생 확률이 이전보다는 높다. (하지만 쓰기 작업을 연마다 하는 특성 상 큰 문제는 되지 않을 것이라 판단하였다)


8. 회고

 데이터 수정이 없는 주소 데이터 특성에 의해 무작정 캐싱 도입을 하였으나, 큰 성능향상은 얻지 못했고, 오히려 Redis 메모리에 대한 잠재적인 문제와, cache miss 시 속도문제를 안게 되었다. 이건 캐싱에 대한 이해도가 부족해 발생한 것이라 생각하여 개념, 용어, 방법, 전략들을 공부하였다.

 

 전략을 선택할 때 가장 중요한 건 캐싱할 데이터의 성격을 분석하는 것이라 생각한다. 메모리 용량은 많아봤자 32GB로 제한적이다. 많은 데이터를 캐싱할 수록 메모리의 부담은 커지고, 메모리 부담을 덜기 위해 무작정 TTL을 낮춘다면, 잦은 DB 혹은 API 통신으로 시간이 더 걸릴 수 있다. 만약 특정 키워드로 반복적인 읽기가 많은 작업이었다면 캐싱을 적용하기 전보다 성능이 낮아질수도 있다.

 조회 작업이 많은지, 쓰기 작업이 많은지, 쓰기 작업 없진 않는지, 있다면 그 빈도는 어떤지, 조회만 하는지 등을 분석하여 메모리 사용량을 낮추고, 속도는 비교적 높일 수 있는 캐싱 전략을 세워야 하는데, 이는 캐싱 데이터의 성격에 따라 달라진다.

 주소 검색의 경우 쓰기 전략을 Write Around를, 읽기 전략은 Look Aside로 설정하고 코드를 개선해나갔다. 주로 읽기 작업이고, 쓰기작업은 연 주기로 공공기관을 통해 데이터를 다운받아 DB에 밀어넣는 것 하나이기 때문이다. 또한 쓰기 작업이 되어 새로 등록된 주소는 cache miss가 발생할 때 조회해도 서비스 운영에는 큰 문제가 발생하지 않고, 즉시 조회를 해야 한다고 해도 캐시를 수동으로 만료시키는 방안도 있었다.

 이렇게 데이터의 성격을 파악하고 전략을 수립한 상태에서 리팩토링을 하니 속도와 메모리 효용성을 향상시킬 수 있는 방법을 구상하고 적용할 수 있게 되었다. 캐싱을 적용하는 것은 어렵지 않다. 다만 데이터의 성격을 분석하여 캐싱을 왜 적용하는지, 어느 부분에 적용해야 좋은지, 적용을 통해 얻을 수 있는 장단점은 어떻고, 적절한 트레이드 오프인지를 생각하는 것이 중요하다고 생각한다.

반응형
반응형

1. 개요

 일반적으로 비지니스 로직 개발만큼 예외처리에 투자하지 않는다. 무성의한 예외처리는 어플리케이션의 많은 버그를 낳을 수 있다. 올바른 예외처리 방법을 알아보자.

 


2. 초난감 예외처리

 개발자들의 코드에서 종종 발견되는 초난감한 예외처리 케이스들이다.

 

2.1. 예외 블랙홀

 예외 상황이 발생해도 이를 무시하고 블랙홀처럼 먹어버리는 케이스이다. 이 경우 아래 코드와 같이 IOException이 발생해도 try/catch 블록을 빠져나가 다음 로직을 계속 수행하게 된다. 이는 비지니스 로직이 스무스하게 흘러갔다는 착각을 가져올 수 있고, 버그를 야기할 수 있다.

try{
	...
}catch(IOException e){
	// 예외 꺼억
}

 

 콘솔에 에러 메시지만 출력시키는 것도 예외 블랙홀이다. 출력시켜도 다음 로직을 수행하는건 마찬가지니까.

try{
	...
}catch(IOException e){
    System.out.println(e);
    e.printStackTrace();
}

 

2.2. 무책임 throws

 catch 블록으로 라이브러리가 던지는 예외들을 매번 throws로 선언하기 귀찮아 상위 클래스인 Exception 클래스를 throws하는 케이스이다. 예외는 이 메서드를 호출한 메서드로 던져지겠지만, 그 메서드에서도 throws하는 것을 반복할 가능성이 높다. 이러한 케이스는 발생할 예외에 대한 정보를 얻을 수 없다. throws Exception만 보고는 파일을 찾지 못한건지, DB 예외가 난건지, IO 예외가 난건지 알 수 없기 때문이다.

//AS-IS
public void test() throws IOException, FileNotFoundException {
        
        ... //CheckedException을 던지는 라이브러리 코드 사용 중
        
 }
 
 //TO-BE → 예외 처리따위 세탁기에 돌려버리자!
 public void test() throws Exception {
        
        ... //CheckedException을 던지는 라이브러리 코드 사용 중
        
 }

 


3. 예외의 종류 및 특징

 예외를 어떻게 다뤄야할지를 알아보기 전, 예외의 종류와 특징을 알아보자. 자바에서 발생시킬 수 있는 예외는 크게 세 가지가 있다. Error, Checked Exception, UnCheckedException.

 

3.1. Error

 Error는 java.lang.Error 클래스의 서브 클래스들이다. Error는 OutOfMemory나 ThreadDeath와 같이 시스템에 비정상적인 상황 발생 시 사용된다.  자바 VM에서 발생시키는 것이므로 어플리케이션 코드에서 잡을 수 없다.

 

3.2. Checked Exception (체크 예외)

 Exception은 Error와 달리 어플리케이션 코드 작업 중 예외상황이 발생했을 경우 사용된다. 이 Exception은 Checked Exception과 Unchecked Exception으로 구분된다. Checked Exception은 RuntimeException을 상속받지 않은 클래스, UnCheckedException은 RuntimeException을 상속한 클래스이다.

Exception의 구조

 

 

 Checked Exception를 발생시키는 메서드를 사용할 경우 반드시 예외 처리 코드를 함께 작성해야 한다. 그렇지 않으면 컴파일 에러가 발생한다. 예를들어 예외를 처리하지 않고 getInputStream() 메서드 사용 시 IOException 예외가 처리되지 않았다는 컴파일 에러가 발생한다. 해당 메서드는 IOException 예외를 던지고 있고, IOException은 Exception을 상속받는다. 즉, IOException 는 Checked Exception 이기 때문에 예외 처리를 하지 않아 컴파일 에러가 발생하는 것이다.

getInputStream() 메서드에서 예외 발생
getInputStream 메서드
Exception 상속

 

3.3. UncheckedException

 RuntimeException 클래스를 상속한 예외들은 예외처리를 강제하지 않기때문에 언체크 예외라고 불린다. 대표적인 예로는 NullPointException이 있다.

 

3.4. 필자가 생각하는 둘의 구분점 

 필자가 생각하는 Checked Exception와 UncheckedException의 구분점은 대비책을 마련할 수 있냐 없냐로 생각한다.

 예외 체크가 된다는 건 예외 발생이 예상 가능하단 뜻이고, 예상 가능하다는 건 대비책을 미리 마련해놓을 수 있다. 만약 어떤 문제가 발생했을 때 대비책을 마련할 가능성이 있다면, Checked Exception을 적용할 수 있다는 것이다.  CheckedException이 트랜잭션 롤백을 하지 않는 이유도 예외 발생 시 적절한 대비책을 마련하여 복구할 가능성이 있기 때문이다.

 반대로 UncheckedException은 대비책을 마련할 필요가 없거나 마련할 수 없다. 대표적인 예로 허용되지 않는 값을 사용해서 메서드를 호출할 때 발생하는 IllegalArugmentException이나, 할당하지 않는 레퍼런스 변수를 사용할 때 발생하는 NullPointException가 있는데 이는 모두 개발자의 실수와 부주의로 발생한다. 이는 어디에서든 일어날 수 있다. 만약 이러한 예외에 대해서도 대비해야 한다면, 모든 코드에 try, catch나 thrwos와 같은 예외처리 방법을 사용해야 할것이다. 이러한 예외들은 그 범위가 매우 방대하므로 대비책을 마련할 필요성이 없다.

 

필자의 팁
만약 Checked Exception과 Unchcked Exception이 잘 구분되지 않는다면, 전자는 컴파일 단계에서 체크(check)하는 Exception이므로 Checked Exception, 후자는 컴파일 단계에서 체크하지 않고(Unchecked) 런타임(Runtime)시 발생할 수 있으므로 UncheckedException이라고 이해해 보는건 어떨까요?

 


4. 예외처리 방법

 이제 예외 처리 방법에 대해 알아보자. 방법으로는 예외 복구, 예외처리 회피, 예외 전환이 있다.

 

4.1. 예외 복구

 말 그대로 예외를 복구하는 방법이다. 여기서의 복구란 '예외 상황을 파악하고, 문제를 해결해서 정상 상태로 돌려놓는 것'이다. 정상 상태로 가는 다른 작업 흐름으로 유도하는 것도 예외 복구로 해석한다.

 예를들어 사용자가 요청한 파일이 없을 경우 IOException이 발생할 것이다. 이 때 사용자에게 문제 상황에 대한 에러 메시지를 뿌려주고, 다른 파일을 이용하도로 안내하는 것도 예외 복구이다.

 

 네트워크가 불안정한 A 시스템은 원격지에 위치한 DB 서버 접속 시 실패하여 SQLException이 종종 발생한다. 이 경우 일정 시간 대기했다가 재 접속을 시도하는 방법을 통해 예외 복구를 시도할 수 있다.

	int maxretry = 5;

        while(maxretry -- > 0){
            try{
                //예외 발생 가능성이 있는 코드
            }catch(Exception e){
                //로그 출력 및 정해진 시간만큼 대기
            }finally {
                //리소스 반납 및 정리작업
            }
        }

        throw new InternalException("에러가 발생하였습니다.");

 

 예외 처리를 강제하는 Checked Exception들은 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다. API를 사용하는 개발자로 하여금 예외가 발생할 수 있음을 인식하도록 도와주고, 적절한 복구를 시도해보도록 요구하는 것이다.

 

4.2. 예외처리 회피

 예외 처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 방법이다. 메서드 시그니처에 throws 문으로 선언하거나, catch 문으로 예외를 잡은 후 로그따위를 남기고 예외를 던지는 것이다.

    public static void exceptionEvasion() throws SQLException{

        try{
            /// JDBC API
            throw new SQLException();
        }catch(SQLException e){
            throw e;
        }
    }

 

 JdbcTemplate에서 사용하는 콜백 오브젝트는 ResultSet이나 Statement 등을 이용해서 작업하다 발생하는 SQLException을 자신이 처리하지 않고 외부로 던진다. 그래서 콜백 오브젝트의 메서드는 모두 throws SQLException이 붙어있다. 예외를 처리하는 일은 템플릿의 역할이라고 보기 때문이다.

 하지만 콜백과 템플릿처럼 긴밀한 관계가 아니라면 자신의 코드에서 발생하는 예외를 그저 던지는 건 무책임한 책임회피일 수 있다. 때문에 긴밀한 관계가 아니라면 자신을 사용하는 쪽에서 예외를 처리하는게 좋다.

JdbcTemplate.update 메서드

 

4.3. 예외 전환

 예외를 다른 예외로 전환하는 방법이다. 전환을 사용하는 두가지 상황이 있다.

 

 첫째, 발생된 예외가 그 예외 상황에 대해 적절한 의미를 부여하지 못할 때이다. 예를들어 회원 가입 시 아이디가 중복될 경우 DB 에러가 발생하면 SQLException이 발생한다. 이때 아래와 같이 예외를 외부로 던진다면, 이를 호출한 메서드에서는 SQLException 발생원인을 알기 어렵다. 

// 회원가입 메서드를 호출하는 메서드
public static void requestJoin(User user){
    try{
    	join(user);
    }catch(SQLException e){
    	// 예외 처리를 해야하는데, SQLException이 왜 발생한거지? 내부 로직을 확인해봐야겠다..
    }
}

// 회원가입 메서드
public static void join(User user) throws SQLException{

    // 1. 회원가입 로직 ...
    // 2. ID, PW 정보 DB INSERT 시 ID 중복(무결정 제약조건 위배)되어 SQLException 발생
    // 3. 메서드 시그니처의 throws SQLException 을 통해 예외 throw
}

  requestJoin 메서드를 보는 개발자 입장에서는 join 메서드에서 SQLException이 발생한다는 건 알 수 있으나 ID가 중복되어 발생했다는 건 바로 알수 없다. SQLException만으로는 ID가 중복으로 인한 예외의 의미를 부여하지 못하기 때문이다. 이때 예외 전환을 사용한다면 다음과 같이 변경할 수 있다.

 

// 회원가입 메서드를 호출하는 메서드
public static void requestJoin(User user){
    try{
    	join(user);
    }catch(DuplicateUserIdException e){ // ID 중복이 발생할 수 있구나!
    	// 예외 처리
    }
}

// 회원가입 메서드
public static void join(User user) throws DuplicateUserIdException{

    try{
        // 1. 회원가입 로직 ...
        // 2. ID, PW 정보 DB INSERT 시 ID 중복(무결정 제약조건 위배)되어 SQLException 발생
    }catch(SQLException e){
        throw new DuplicateUserIdException(e);  // 3. DuplicateUserIdException 예외 throw
    }
        
}

 이때는 DuplicationUserIdException 예외 자체가 ID 중복의 의미를 담고 있기 때문에 개발자도 예외 원인을 한눈에 파악할 수 있다.

 

 둘째, checked Exception을 UncheckedException으로 바꿀때(혹은 포장할 때)이다.

try{
    OrderHome orderHome = EJBHomeFactory.getInstance().getOrderHome();
    Order order = orderHome.findByPrimaryKey(Integer id);
}catch(NamingException ne){
	throw new EJBException(ne);
}catch(SQLException se){
	throw new EJBException(se);
}catch(RemoteException re){
	throw new EJBException(re);
}

 위 코드에서 EJBException은 런타임 예외(Unchekced Exception) 이고 나머지 예외는 Checked Exception이다. 런타임 예외의 큰 특징 중 하나는 시스템 오류로 판단하고 트랜잭션을 자동으로 롤백해주는 것이다. 이 코드는 단순히 런타임 예외로 포장만 했지만, 트랜잭션 롤백 처리도 하게 되었다. 또한, 위 메서드를 호출하는 다른 메서드에서는 EJBExceptoin에 대한 예외를 처리할 필요가 없으니 메서드의 시그니처나 로직이 변경될 필요가 없다. 변경에 닫혀있으니 OCP를 잘 따른다고 할 수 있다.


5. 예외 처리 전략

 이러한 예외 처리 방법을 통해 어떤 형식의 예외 처리 전략을 가져가야 할지 알아보자.

 

5.1. 런타임 예외를 보편적으로 사용하자

 체크 예외가 발생할 경우 사실상 예외를 복구하는 것보다 해당 요청의 작업을 취소하고 개발자에게 통보하는 편이 낫다. 대게 체크 예외는 복구가 불가능한 상황이 많기 때문이다. 실제로 자바의 환경이 서버로 이동하면서 체크 예외의 활용도가 떨어지고 있으며 프레임워크에서도 API가 발생시키는 예외를 체크 예외 대신 런타임 예외로 정의하는 것이 일반화 되고 있다. OCP나 트랜잭션 등 앞서 알아본 런타임 예외의 이점을 생각해봤을 때 예외는 런타임 예외로 정의하는 것이 보편적이다.

 

5.2. 체크 예외는 런타임 예외로 전환하자.

 체크 예외가 발생할 경우 런타임으로의 예외로 전환/포장하는 방법을 적절히 사용하자. 아래와 같이 SQLExcetion이라는 체크 예외 발생할 경우 예외 클래스의 에러 코드를 통해 ID 중복으로 발생한 예외인지를 체크할 수 있다. 이렇게 체크한 후 DuplicationUserIdException() 따위의 런타임 예외로 전환/포장하는 전략을 사용하자. 이렇게 되면 의미있는 클래스를 통해 예외 처리가 가능하며, 외부에서는 이 예외에 대한 처리를 신경쓰지 않아도 된다.

public static void join(User user) throws DuplicateUserIdException{

        try{
            // 1. 회원가입 로직 ...
            // 2. ID, PW 정보 DB INSERT 시 ID 중복(무결정 제약조건 위배)되어 SQLException 발생

        }catch(SQLException e){
            if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY){
                throw new DuplicateUserIdException(e);  // 예외 포장/전환
            }else{
            	throw new RuntimeException(e); // 예외 포장
            }
        }
    }

6. 애플리케이션 예외

 애플리케이션 자체의 로직에 의해 의도적으로 발생시키는 예외를 말한다. 예를들어 사용자가 요청한 금액을 출금하는 메서드는 요청한 금액보다 잔고가 많을 경우에만 출금이 가능하다. 반대 상황일 때에는 애플리케이션 로직에서 의도적으로 예외를 발생시킨 후 사용자에게 잔고가 부족하다는 등의 메시지를 전달해야 한다. 아래 코드가 그 예이다.

try{
	BigDecimal balance = account.withdraw(amount);
	// 정상적인 처리 결과를 출력하도록 진행
}catch (InsufficientBalanceException e){ // Checked Exception
	// InsufficientBalanceException에 잔고금액 정보를 가져옴
	BigDecimal availFunds = e.getAvailFunds();
            
	// 잔고부족 안내 메시지를 준비하고 이를 출력하도록 진행
}

 InsufficientBalanceException은 Checked Exception으로 생성하였는데, 위와 같이 예외를 복구하는 로직을 강제하여 추가하기 위함이다.

 


7. JdbcTemplate의 SQLException

 예제 코드인 UserDao를 보면 스프링에서 제공하는 JdbcTemplate를 도입을 하면서 SQLException에 대한 예외 처리(예외 회피) 코드인 throws SQLException 구문이 모두 사라진 것을 알 수 있었다. SQLException에 대한 예외처리를 하지 않아도 컴파일 에러가 발생하지 않는 상황이다. SQLException은 ChekcedException이니 예외 처리가 강제되어야 하는데 말이다.

 호출하는 메서드를 확인해보니 콜백 오브젝트에서도 throws SQLException을 통해 예외를 던지고 있다. 이상하다고 생각했지만, 앞서 배운 내용을 적용하니 이유를 알 수 있었다.

JdbcTemplate.update에서 SQLException을 throws 하고있음
deleteAll 메서드에서 SQLException에 대한 예외처리를 하지 않음.

 

 

 * SQLException은 복구가 가능한가? 

 먼저 SQLException은 예외 복구가 가능할까? SQL 문법이 틀리거나, 제약조건을 위배하거나, DB 커넥션이 끊기는 등 다양한 이유로 발생하는 대부분의 예외들은 예외 복구가 불가능하다. 그렇기에 현업에서도 이러한 예외 처리는 에러 로그를 남기고 예외에 대한 메시지를 사용자에게 알려주는 방식으로 처리되었다. 그래서인지 이 메서드들은 콜백 메서드에선 SQLException을 던지고 있지만, 예외 전환/포장 전략을 사용하여 DataAccessException이라는 런타임 예외로 변환하여 던지고 있다.

 

 아래 update 메서드에서 콜백 객체 생성 후 execute() 메서드를 호출하는데 update() 메서드의 throws를 보면 DataAccessException을 던지고 있다.

JdbcTemplate.update에서 SQLException을 throws 하고있음

 

 호출되는 execute 메서드에 SQLException 예외를 처리하는 부분이 있는데, getExceptionTranslator().translate() 메서드를 호출한 결과를 throw 하는 것을 볼 수 있다.

JdbcTemplate.execute 메서드

 

 그리고 이 메서드가 SQLException을 DataAccessException으로 전환해주고 있었다. 즉 Checked Exception을 RuntimeException으로 전환하는 예외 전환을 사용하고 있고, RuntimeException은 예외처리 강제성이 없으므로 예외처리를 하지 않았던 것이다.

 이 외에 다른 메서드들이나, 스프링에서 제공하는 API 메서드들도 대부분 런타임 예외를 사용하고 있다.

SQLExceptionTranslator.translate 메서드 시그니처

Translate the given SQLException into a generic DataAccessException.
= 주어진 SQLException을 일반 DataAccessException으로 변환합니다

 

 정리하면, 콜백 오브젝트 메서드에서 발생한 Checked Exception을 Runtime Exception으로 예외 전환/포장하는 전략을 사용하여 외부로 던지고 있기 때문에 SQLException에 대한 예외처리가 강제되지 않고, 메서드 사용 시 예외처리를 신경쓸 필요도 없게 되었다.

 

DataAccessException
DataAccessExceptoin 는 런타임 예외중 하나로, 자바의 다양한 데이터 액세스 기술을 사용할 때 발생하는 예외들을 추상화한 것들을 계층구조 형태로 모아놓은 클래스이다.
 이 클래스는 단순 SQLException을 전환하는 용도로만 사용되는 건 아니다. JDBC 뿐 아니라 JPA, 하이버네이트와 같은 ORM에서 발생하는 예외들도 이 클래스의 계층구조에 관리되어 있다. 예를들어 JDBC, JPA, 하이버네이트에 상관없이 데이터 액세스 기술을 부정확하게 사용했을 때는 InvalidDataAcessResourceUsageException 예외가 던져진다. 이는 각각 또 다른 예외로 세분화된다.

 


8. 기술에 독립적인 UserDao 만들기

 

8.1. 인터페이스 적용

 JDBC나 JPA와 같은 DB 데이터 처리 기술에 독립적인 UserDao를 만들기 위해 작성했던 UserDao 클래스를 인터페이스와 구현클래스로 분리해보자. 인터페이스는 접두사에 I를 붙여 만들고, 기존 UserDao 이름을 UserDaoJdbc로 변경하자. 나중에 JPA로 구현한다면 UserDaoJpa라고 이름을 붙일 수 있다. 인터페이스 분리를 통해 변경되고 생성된 코드들은 다음과 같다.

 

8.1.1. IUserDao.java

public interface IUserDao {

    public void add(User user);
    public void deleteAll();
    public User get(String id);
    public int getCount();
    public List<User> getAll();
}

 

8.1.2. UserDaoJdbc.java

public class UserDaoJdbc implements IUserDao{

    private JdbcTemplate jdbcTemplate;

    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"));
            return user;
        }
    };

    public void setDataSource(DataSource dataSource){
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
    public void add(final User user)  {
        jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",
                user.getId(), user.getName(),user.getPassword());
    }

    public void deleteAll(){
        jdbcTemplate.update("delete from users");
    }


    public User get(String id){
        return jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id},
                userMapper);
    }

    public int getCount(){
        return jdbcTemplate.queryForInt("select count(*) from users");
    }

    public List<User> getAll() {
        return jdbcTemplate.query("select * from users order by id",
                userMapper);
    }
}

 

8.1.3 applicationContext.xml

...

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

 

8.1.4 UserDaoJdbcTest.java

class UserDaoJdbcTest {

    private IUserDao userDaoJdbc; //인터페이스 형으로 변경
    private User user1;
    private User user2;
    private User user3;

    @BeforeEach
    void setUp(){
        ApplicationContext applicationContext = new GenericXmlApplicationContext("applicationContext.xml");
        userDaoJdbc = applicationContext.getBean("userDaoJdbc", UserDaoJdbc.class); // userDaoJdbc 빈 조회
        user1 = new User("test1","1234","테스터1");
        user2 = new User("test2","12345","테스터2");
        user3 = new User("test3","123456","테스터3");
    }
    
    ...

 

 이로써 UserDaoJdbc는 전략패턴을 사용하여 JDBC 기술에 독립된 클래스로 관리되었다.

UserDao 인터페이스 및 구현체 분리

 

8.2. 테스트

 코드가 수정되었으니 테스트 코드를 실행시켜 단위 테스트를 진행해보자. 예제에서 ID 중복에 대한 테스트 코드를 추가하길래 JUnit5에 맞게 메서드를 만들어 주었다.

    @Test
    public void duplicateId(){
        userDaoJdbc.deleteAll();
        userDaoJdbc.add(user1);
        assertThatThrownBy(()-> userDaoJdbc.add(user1)).isInstanceOf(DataAccessException.class);
    }

 


9. 회고

 Checked Exception과 UncheckedException을 사용하는 이유, OCP 관점에서 본 둘의 차이, 특징에 의거하여 체크 예외가 트랜잭션을 롤백하지 않는 이유, 예외처리 방법과 전략 등 예외에 대한 다양한 내용들을 알아보았다. 이 과정들을 스프링에서 제공하는 JdbcTemplate에 적용하니 이해가 훨씬 잘되었다. 출판한지 오랜된 책이지만 요즘 사람들의 입에 자주 언급될만큼의 가치들이 이런곳에서 나오는 것 같다. 

 예외 처리는 가벼히 여겨서는 안될 어플리케이션의 중요 요소 중 하나라고 생각한다. 이러한 코드들을 구현할 때 이번에 배운 내용들이 내가 작성하는 코드의 자신있는 근거가 될 수 있을것이라 확신한다.

반응형
반응형

1. 개요

 - 토비의 스프링 책에 나온 계산기 예제 코드에 대해 템플릿/콜백 패턴을 적용한다.

 - 패턴을 도출해내는 과정을 이해하기 위함이다.


2. 템플릿/콜백 패턴이란?

 - 전략 패턴에 사용되는 인터페이스 구현체 클래스 대신 익명 내부 클래스가 사용되는 패턴이다. (전략패턴 + 익명 내부 클래스)

 - 코드 내에 고정된 부분(템플릿)과 변경되는 부분이 있을 때 고정된 부분은 클래스 또는 메서드로 분리하여 템플릿 역할을 갖게하고, 변경되는 부분 콜백(익명 클래스의 메서드)으로 처리한다.

 - 콜백으로 처리하는 이유는 변경되는 부분이 여러 클래스에서 재사용되는게 아닌 특정 클래스의 메서드 내에서 한번만 사용되기 때문이다. 만약, 여러 클래스에서 사용된다면 템플릿/콜백 메서드보다는 전략 패턴을 사용하는게 더 유리하다.

 


3. 템플릿/콜백 패턴 적용

 

3.1. 예제코드

public class Calculator{

    public Integer calcSum(String path) throws IOException {
        BufferedReader br = new BufferedReader(new FileReader(path));

        Integer sum = 0;
        String line = null;

        while((line = br.readLine()) != null){
            sum += Integer.valueOf(line);
        }

        br.close();
        return sum;
    }
}

 - path 경로에 있는 txt 파일을 읽어 각 라인에 적혀있는 숫자들을 연산하는 예제이다.

 

3.2. 코드 분석 및 템플릿 설계

 - 코드를 분석하여 어떤 전략을 사용해 템플릿을 설계할지 생각해본다. 일단 예제의 목적 자체가 템플릿/콜백 패턴 구현이므로 이에 초점을 맞추었다. 현재  'calcSum' 이라는 더하기 연산 처리 메서드만 있지만 곱하기, 나누기, 빼기의 메서드가 추가된다고 가정하고 분석하였다.

 

아래 사항들을 생각하며 분석하였다.

 1) 공통부분과 바뀌는 부분이 있는지

 2) 공통부분은 어떤 레벨로 분리할지

 3) 바뀌는 부분은 타 클래스 혹은 메서드에서 재사용할 가능성이 있는지

 

3.3. 분석결과

3.3.1. 공통부분

- path에 대한 BufferedReader를 생성하는 부분

- 반복문을 통해 BufferedReader에서 라인을 읽는 부분

- BufferedReader를 close 하는 부분 (+ try, catch, finally 로 처리)

 

3.3.2. 바뀌는 부분

- 값을 연산하는 부분

 

3.3.3. 정리

- 공통 부분은 BufferedReader의 생명주기와 관련있고, Integer형태의 결과값을 리턴하는 부분이므로 다른 클래스에서도 충분히 재사용할 수 있다고 판단. 클래스로 분리한다.

- 더하기, 빼기, 곱하기, 나누기 연산은 메서드마다 유연하게 바뀌어야 하므로 템플릿/메서드 패턴보다는 전략패턴을 고려하였으나, 각 연산 처리는 다른 클래스에서 재사용되지 않고, Calculator 클래스에 생성될 메서드에 한번만 사용될 것이기 때문에 일회성 성격을 지닌 내부 익명 클래스로 구현한다. 익명 클래스는 콜백 오브젝트 역할을 수행할 것이며, 이를 통해 템플릿/콜백 패턴을 적용한다.

 

3.4. 1차 코드수정

3.4.1. Calculator2.java

public class Calculator2 {

	// 공통 부분을 구현한 Template 클래스
    private BufferedReaderTemplate bufferedReaderTemplate;

	// 외부로부터 DI받기위한 수정자 메서드
    public void setBufferedReaderTemplate(BufferedReaderTemplate bufferedReaderTemplate){
        this.bufferedReaderTemplate = bufferedReaderTemplate;
    }

	// 더하기 메서드
    public Integer calcSum(String path) throws IOException {

		// Template의 withCalculatorCallback 를 호출 시 CalculatorCallback에 대한 내부 익명클래스 구현 및 전달
        return bufferedReaderTemplate.withCalculatorCallback(path, new CalculatorCallback() {
            @Override
            public Integer calculate(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        });
    }
    
    // 곱하기 메서드
    public Integer calcMultiply(String path) throws IOException {
		// Template의 withCalculatorCallback 를 호출 시 람다식을 활용하여 내부 익명클래스 구현 및 전달
        return bufferedReaderTemplate.withCalculatorCallback(path, (line, value) -> value * Integer.valueOf(line));
    }
}

 - 여러 클래스에서 공통으로 사용될 여지가 있는 BufferedReaderTemplate은 외부(ObjFactory.java)로부터 DI 받는다.

 - 메서드 호출 시 템플릿/콜백 패턴이 적용된 bufferedReaderTemplate.withCalculatorCallback() 메서드를 호출하며, 이때 두번째 파라미터인 CalculatorCallback() 인터페이스에 대한 구현체를 내부 익명클래스로 구현한다.

 - 곱하기 메서드는 람다식을 사용하여 간단하게 구현했다. 사실상 더하기 메서드와 동일하다. 

 

3.4.2. BufferedReaderTemplate.java

public class BufferedReaderTemplate {

	// CalculatorCallback을 함께 받는 메서드 정의
    public Integer withCalculatorCallback(String path, CalculatorCallback callback) throws IOException {
        BufferedReader br = null ;
        try{
            br = new BufferedReader(new FileReader(path));
            String line;

            Integer res = 0;
            while((line = br.readLine()) != null) {
                res = callback.call(line, res); // 콜백 오프젝트의 call 메서드 호출
            }

            return res;
        }catch(FileNotFoundException e){
            e.printStackTrace();
            throw e;
        }finally {
            if(br != null){
                br.close();
            }
        }

    }
}

 - CalculatorCallback 구현체를 파라미터로 받는 withCalculatorCallback 메서드를 구현하였다. 만약 계산기가 아닌 다른 목적으로 이 클래스를 사용한다면 그에 맞게 메서드를 만들수 있으므로 확장성이 보장된다.

 - 중간 부분에 callback.call(line,res); 구문을 통해 콜백 오프젝트의 call 메서드를 호출하여 연산 처리가 되도록 하였다.

 

3.4.3. CalculatorCallback.java

public interface CalculatorCallback {

    public Integer call(String line, Integer value);
}

 - 템플릿에 사용될 콜백 오브젝트를 정의하였다. 목적은 파라미터로 들어온 line의 값과 value 의 값을 목적에 맞게 연산하기 위함이다.

 

3.4.4. ObjFactory.java

@Configuration
public class ObjFactory {

    @Bean
    public Calculator2 calculator2(){
        Calculator2 calculator2 = new Calculator2();
        calculator2.setBufferedReaderTemplate(bufferedReaderTemplate());
        return calculator2;
    }

    @Bean
    public BufferedReaderTemplate bufferedReaderTemplate(){
        return new BufferedReaderTemplate();
    }
}

 - DI 처리를 위한 Factory 클래스이다. BufferedReaderTemplate Bean을 만들고 Calculator2에 DI 하고있다.

 

3.5. 1차 테스트

 - Junit 코드 작성 후 테스트를 진행하였다. DI 정보는 ApplicationContext에서 읽어와 멤버필드에 넣어주었다.

 - numbers.txt 파일은 test 패키지의 resource 경로에 넣어두고, getResource().getPath() 메서드를 통해 파일 경로를 읽어 멤버필드에 넣어주었다.

 - numbers.txt 파일 내용은 아래와 같다.

number.txt

1
2
3
4
5

 

3.5.1. 테스트 코드

public class PracticeCalculatorTest {

    private Calculator2 calculator;
    private String filePath;

    @BeforeEach
    void setUp(){
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(ObjFactory.class);
        this.calculator = applicationContext.getBean("calculator2", Calculator2.class);
        this.filePath = getClass().getResource("/numbers.txt").getPath();
    }
    @Test
    public void sumOfNumbers() throws IOException {

        int sum = calculator.calcSum(filePath);

        assertThat(sum).isEqualTo(15);
    }

    @Test
    public void multiplyOfNumbers() throws IOException {

        int sum = calculator.calcMultiply(filePath);

        assertThat(sum).isEqualTo(120);
    }
}

 

3.5.5. 테스트 결과

 - 더하기는 문제가 없으나, 곱하기에서 실패한다. 원인은 템플릿 메서드인  withCalculatorCallback에 있었다. 최초 res 값이 0일 경우 어떤 값을 곱해도 0이 나오기 때문이다. 곱하기일 경우 변하는 값에 최초 res값이 있다는 사실을 놓쳤다.

1차 테스트 결과

 

3.6. 2차 코드수정

 - 최초 res 값을 처리할 부분을 생각해보자. 최초 값을 CalculatorCallback의 파라미터로 넣는 방법이 있고, withCalculatorCallback의 파라미터로 넣는 방법이 있다.

- CalculatorCallback는 String 형태로 들어온 line 값과 두번째 파라미터인 현재 결과 값을 연산하는 메서드이다. 만약 이 부분에 최초 응답 값이 있을 경우 분기하는 로직이 들어갈 것으로 예상되기에 withCalculatorCallback 메서드의 파라미터로 넘기는 방법을 선택했다.

 

3.6.1. Calculator2.java

public class Calculator2 {

    private BufferedReaderTemplate bufferedReaderTemplate;

    public void setBufferedReaderTemplate(BufferedReaderTemplate bufferedReaderTemplate){
        this.bufferedReaderTemplate = bufferedReaderTemplate;
    }

    public Integer calcSum(String path) throws IOException {

        return bufferedReaderTemplate.withCalculatorCallback(path, new CalculatorCallback() {
            @Override
            public Integer call(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        },0); // 최초 값인 0 추가
    }
    public Integer calcMultiply(String path) throws IOException {

		// 최초 값인 1 추가
        return bufferedReaderTemplate.withCalculatorCallback(path, (line, value) -> value * Integer.valueOf(line),1);
    }
}

 - withCalculatorCallback에 최초 값에 대한 파라미터를 전달하였다. 더하기는 0, 곱하기는 1이다.

 

3.6.2. BufferedReaderTemplate.java

public class BufferedReaderTemplate {
	
    // initVal 추가
    public Integer withCalculatorCallback(String path, CalculatorCallback callback, Integer initVal) throws IOException {
        BufferedReader br = null ;
        try{
            br = new BufferedReader(new FileReader(path));
            String line;

            Integer res = initVal; // initVal을 res값에 대입
            while((line = br.readLine()) != null) {
                res = callback.call(line, res);
            }

            return res;
        }catch(FileNotFoundException e){
            e.printStackTrace();
            throw e;
        }finally {
            if(br != null){
                br.close();
            }
        }

    }
}

 - 메서드에 대한 시그니처를 수정하고, res = initVal을 넣어 최초 값을 설정해주었다.

 

3.7. 2차 테스트

 

3.7.1. 테스트 결과

 - 더하기와 곱하기 테스트가 성공함을 확인할 수 있다.

2차 테스트 결과

 


4. 회고

 토비의 스프링 3장 템플릿 부분 중 템플릿/콜백 패턴의 사용을 도출해내는 과정이 잘 이해되지 않아 코드 분석 및 적용 패턴 도출 과정을 근거와 함께 생각해보며 구현해보았다. 패턴 적용이 완료된 코드를 한번 작성해본터라 그 코드를 따라가는 느낌도 없지않아 있었지만, 코드를 분석하고, 패턴을 사용하는 근거를 생각하는 과정을 통해 템플릿/패턴을 사용하는 이유를 보다 잘 이해하게 되었다.

 스프링에서 제공하는 많은 클래스들은 이러한 패턴들의 조합으로 이루어져있다고 한다. 여러 패턴들을 소비해보고 그 원리를 이해하려고 노력하는 게 스프링을 제대로 사용하는 것이 아닐까라는 생각이 문뜩 드는 하루였다. ㅎㅎ

반응형
반응형

1. 템플릿/콜백 패턴

 - 전략 패턴의 기본구조 + 익명 내부 클래스

 

템플릿
일반적으로 템플릿은 어떤 목적을 위해 미리 만들어둔 틀을 뜻한다. 프로그래밍에서는 어떤 고정된 패턴안에 바꿀 수 있는 부분을 넣어서 사용하는 경우 템플릿이라고 부른다.
콜백
콜백은 메서드가 실행되는 것을 목적으로 다른 오브젝트 메서드에 전달되는 오브젝트를 말한다. 자바에선 메서드 자체를 파라미터로 전달할 수 없기때문에 메서드가 담긴 오브젝트를 전달하며, 이러한 오브젝트를 펑셔널 오브젝트(functional object)라고도 한다.
전략패턴
전략 패턴은 자신의 기능 중 필요에 따라 변경이 필요한 로직을 인터페이스를 통해 외부로 분리시키고, 이를 구현한 구체적인 로직을 필요에 따라 바꿔 사용할 수 있게 하는 디자인 패턴이다.

 

1.1. 예제

public void deleteAll() throws SQLException{
	// 전략패턴 + 익명 내부 클래스 사용 = 템플릿/콜백 패턴
        jdbcContext.workWithStatementStrategy(new StatementStrategy() {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                return c.prepareStatement("delete from users");
            }
        });
    }

 - jdbcContext.workWithStatementStrategy 메서드 파라미터의 인자에 인터페이스에 대한 구현 클래스가 아닌 익명 클래스를 넣고 있다. (== 전략 패턴의 기본구조 + 익명 내부 클래스)

 

1.2. 동작원리

템플릿/콜백의 작업 흐름

1) 클라이언트는 템플릿 안에서 실행될 콜백 오브젝트를 만든다.

2) 만든 콜백과 함께 템플릿을 호출한다. (메서드 레벨 DI)

3, 4) 템플릿에서는 사용할 참조정보를 생성한다.

5) 클라이언트로부터 전달받은 콜백을 참조변수와 함께 호출한다.

6) 콜백 함수에서는 Client의 final 변수를 참조한다.

7) 콜백 비지니스 로직을 수행한다.

8) 콜백 리턴 값을 템플릿에 전달한다.

9, 10) 템플릿에서는 나머지 로직을 수행한다. 

11) 최종적으로 템플릿의 결과 값을 클라이언트에게 리턴한다.

 

1.3. 단점

 - 익명 클래스 사용으로 인해 일반적인 코드보다는 상대적으로 코드를 작성하고 읽기가 불편하다. 하지만 익명클래스 코드 부분을 재사용하도록 템플릿화 시킨다면 코드 작성은 훨씬 간단해질 것이다. 

 

1.4. 콜백 분리 및 재사용

1) 메서드 분리

 - 기존 deleteAll 메서드에서 바뀌는 부분은 오직 SQL 부분이다. UserDao에 SQL을 받는 메서드를 만들고, 재사용하도록 구현한다. 이로써 응답 값이 없는 요청은 executeSql 메서드에 SQL만 넣어 호출하면 된다.

public void deleteAll() throws SQLException{
        executeSql("delete from users");
}

...

 public void executeSql(final String query) throws SQLException{
        jdbcContext.workWithStatementStrategy(
                new StatementStrategy() {
                    @Override
                    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                        return c.prepareStatement(query);
                }
        });
}

 

2) 클래스 분리

- UserDao 클래스 뿐 아니라 모든 Dao 클래스에서도 executeSql 메서드를 재사용할 수 있도록 executeSql 메서드를 JdbcContext 클래스로 이동시켜주자. 이로써 UserDao의 구현부에는 익명 클래스를 구현할 필요가 없어졌다.

 

1.5. executeSql 메서드를 새로운 클래스로 분리하지 않은 이유

 - 일반적으로 기능을 분리할 때에는 클래스로 분리하는 것이 재사용성이 좋다. executeSql도 클래스를 따로 생성할 수 있었지만, JdbcContext 클래스로 이동시킨 이유는 응집도 때문이다. JdbcContext는 DB와의 Connection을 맺고 쿼리를 실행시키는 책임이 있다. executeSql 메서드도 마찬가지로 이 책임을 위해 존재하므로 JdbcContext와 긴밀한 관계를 맺고있다고 볼 수 있다. 이렇게 동일한 목적을 가진 긴밀한 코드들은 한 군데 모여있는게 좋다.

 


2. 템플릿/콜백 패턴 예제 실습

2.1. 계산기 예제 코드 작성

 - 텍스트 파일에서 숫자를 읽어와 계산 기능을 처리하는 코드를 작성한다.

public class Calculator {
    public int calcSum(String path) throws IOException {

        BufferedReader br = new BufferedReader(new FileReader(path));
        Integer sum = 0;

        String line = null;

        while((line = br.readLine()) != null){
            sum += Integer.valueOf(line);
        }

        br.close();
        return sum;
    }
}

 

2.2. 코드 분석

 - 현재는 더하기 메서드만 구현하였으나, 곱하기, 나누기 메서드도 생성된다 가정하고 중복 또는 바뀌는 부분을 찾아본다.

 * 중복되는 부분

 1) path에 대한 BufferedReader가 생성 부분

 2) close 되는 부분 - try, catch, finally 포함 예정

 

 * 바뀌는 부분

 1) 읽은 문자열에 대해 계산하는 부분

 

2.3. 코드 리팩토링

 - 중복되는 부분을 메서드화 한다. 다른 클래스에서 재사용하지 않기 때문에 클래스로 분리하지 않았다.

 - 바뀌는 부분에 대해서는 템플릿/콜백 패턴을 적용하여 익명 클래스를 구현하도록 하였다.

 - 곱하기를 처리하는 calcMultiplay 메서드도 구현하였다.

public class Calculator {
    public int calcSum(String path) throws IOException {

        return fileReaderTemplate(path, new BufferedReaderCallback() {
            @Override
            public Integer doSomeThingWithReader(BufferedReader br) throws IOException {
                Integer sum = 0;
                String line;

                while((line = br.readLine())!= null){
                    sum += Integer.valueOf(line);
                }
                return sum;
            }
        });
    }

    public int calcMultiply(String path) throws IOException{

        return fileReaderTemplate(path, new BufferedReaderCallback() {
            @Override
            public Integer doSomeThingWithReader(BufferedReader br) throws IOException {
                Integer multiply = 1;
                String line;

                while((line = br.readLine())!= null){
                    multiply *= Integer.valueOf(line);
                }
                return multiply;
            }
        });
    }

    public Integer fileReaderTemplate(String filePath, BufferedReaderCallback callback) throws IOException {
        BufferedReader br = null;

        try{
            br = new BufferedReader(new FileReader(filePath));
            int ret = callback.doSomeThingWithReader(br);
            return ret;
        } catch (IOException e) {
            e.printStackTrace();
            throw e;
        } finally {
            if(br != null){
                try{br.close();}catch(Exception e){e.printStackTrace();}
            }
        }
    }
}

...

public interface BufferedReaderCallback {

    Integer doSomeThingWithReader(BufferedReader br) throws IOException;
}

 

2.4. 2차 코드 분석

 - 구현된 메서드들에 대해 2차적으로 중복 또는 바뀌는 부분을 찾아본다.

 * 중복되는 부분

 1) line을 읽는 부분

 2) 계산된 결과를 리턴하는 부분

 

 * 바뀌는 부분

 1) 읽은 line에 대해 연산을 수행하는 부분

 2) 계산 시작 값 (더하기 = 0, 곱하기 = 1)

 

2.5. 2차 코드 리팩토링

 - 마찬가지로 중복되는 부분을 메서드로 생성한다. 기존 fileReaderTemplate을 lineReadTemplate으로 수정하여 구현했다.

 - LineCallback 인터페이스는 읽은 line에 대한 값과 파라미터 값을 더하거나, 곱하기 위해 생성했다.

 - initVal는 초기 값으로 더하기일 경우 0을, 곱하기일 경우 1을 넣어준다.

 

public interface LineCallback {
    Integer doSomethingWithLing(String line, Integer value);
}

...
public class Calculator {
    public int calcSum(String path) throws IOException {

        return lineReadTemplate(path, new LineCallback() {
            @Override
            public Integer doSomethingWithLing(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        }, 0);
    }

    public int calcMultiply(String path) throws IOException{

        return lineReadTemplate(path, new LineCallback() {
            @Override
            public Integer doSomethingWithLing(String line, Integer value) {
                return value * Integer.valueOf(line);
            }
        }, 1);
    }
    
    public Integer lineReadTemplate(String filePath, LineCallback callback, int initVal) throws IOException {
        BufferedReader br = null;
        try{
            br = new BufferedReader(new FileReader(filePath));
            int res = initVal;
            String line;

            while((line = br.readLine()) != null){
                res = callback.doSomethingWithLing(line, res);
            }
            return res;
        } catch (IOException e) {
            e.printStackTrace();
            throw e;
        } finally {
            if(br != null){
                try{br.close();}catch(Exception e){e.printStackTrace();}
            }
        }
    }
}

 

 - 이로써 로우 레벨의 파일 처리 코드가 템플릿으로 분리되고, 연산 관련 메서드들은 데이터를 가져와 계산한다는 기능에 충실한 코드만을 갖게 되었다.

 


3. 스프링의 JdbcTemplate

 - 예제를 통해 자바 코드를 통한 DB 처리 로직을 개발했으나, 스프링에서도 JDBC를 이용하는 DAO에서 사용할 수 있도록 준비된 다양한 템플릿과 콜백을 제공한다. 그 중 하나가 JdbcTemplate이다. JdbcContext에서 JdbcTemplate을 사용하는 코드로 DAO를 변경해보자. JdbcTemplate은 생성자 파라미터로 DataSource를 주입해주면 된다.

 

3.1. UserDao 리펙토링

public class UserDao {

    private JdbcTemplate jdbcTemplate;

    private DataSource dataSource;

    public void setDataSource(DataSource dataSource){
        this.jdbcTemplate = new JdbcTemplate(dataSource);
        this.dataSource = dataSource;
    }
    
    ...
}

 

3.2. UserDao 메서드 리팩토링

 - 기존 코드는 UserDao에서 jdbcContext를 주입받고, executeSql을 실행하고 있다. executeSql은 PreparedStatement를 생성하는 익명 클래스를 구현한 후 workWithStatementStrategy 메서드에 전달하고, 여기서 DB 연결 및 executeUpdate를 통한 DB 쿼리 처리가 실행된다.

public void deleteAll() throws SQLException{
     jdbcContext.executeSql("delete from users");
}

...

public void executeSql(final String query) throws SQLException{
        workWithStatementStrategy(
                new StatementStrategy() {
                    @Override
                    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                        return c.prepareStatement(query);
                    }
                }
        );
    }
    
 public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException {
        Connection c = null;
        PreparedStatement ps = null;

        try{
            c = dataSource.getConnection();

            ps = stmt.makePreparedStatement(c);

            ps.executeUpdate();

            // execute : 수행 결과를 Boolean 타입으로 반환
            // executeQuery : select 구문을 처리할 때 사용하며, 수행 결과를 ResultSet 타입으로 반환
            // executeUpdate : INSERT, UPDATE, DELETE 구문을 처리할 때 사용하며, 반영된 레코드 수를 int 타입으로 반환.
        }catch(SQLException e){
            e.printStackTrace();
            throw e;
        }finally {
            if(ps != null){ try{ ps.close();} catch (Exception e){e.printStackTrace();} }
            if(c != null){ try{ c.close(); }catch(Exception e){ e.printStackTrace();} }
        }
    }

 

하지만 JdbcTemplate을 사용하면 아래와 같이 단 한줄로 작업이 끝난다. SQL 쿼리만 넘기면 된다.

public void deleteAll() throws SQLException{
        this.jdbcTemplate.update("delete from users");
    }

 

이게 가능한 이유는 JdbcTemplate에서도 우리가 지금껏 했던 작업과 같이 템플릿화 되있기 때문이다. 내부 코드 일부를 보면 알 수 있듯이 여기서도 메서드/콜백 패턴이 사용됨을 알 수 있다.

 

public int update(final String sql) throws DataAccessException {
		Assert.notNull(sql, "SQL must not be null");
		if (logger.isDebugEnabled()) {
			logger.debug("Executing SQL update [" + sql + "]");
		}
       		// 메서드 콜백 패턴 사용
		class UpdateStatementCallback implements StatementCallback<Integer>, SqlProvider {
			public Integer doInStatement(Statement stmt) throws SQLException {
				int rows = stmt.executeUpdate(sql);
				if (logger.isDebugEnabled()) {
					logger.debug("SQL update affected " + rows + " rows");
				}
				return rows;
			}
			public String getSql() {
				return sql;
			}
		}
        
        	// 추가 비지니스 로직 처리
		return execute(new UpdateStatementCallback());
	}

 

나머지 add, getCount, get 메서드 또한 jdbcTemplate을 사용한 방식으로 리팩토링 한다.

public void add(final User user)  {
	jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",
    		user.getId(), user.getName(),user.getPassword());
}
public int getCount(){
	return jdbcTemplate.queryForInt("select count(*) from users");
}
public User get(String id){
        return jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id},
                new RowMapper<User>() {
                    @Override
                    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"));
                        return user;
                    }
                });
    }
public List<User> getAll() {
        return jdbcTemplate.query("select * from users order by id",
                new RowMapper<User>() {
                    @Override
                    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"));
                        return user;
                    }
                });
    }

 

3.3. 중복 코드 제거

 - RowMapper 오브젝트를 생성하는 부분이 중복되어있다. 사용되는 RowMapper를 보면 상태정보가 없다. 즉, 먼저 생성해놓고 재사용해도 무관하다는 뜻이다.

 

public class UserDao {

	...
    
	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"));
            return user;
        }
    };
    
	...
    
    public User get(String id){
        return jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id},
                userMapper);
    }
    
    public List<User> getAll() {
        return jdbcTemplate.query("select * from users order by id",
                userMapper);
    }
}

4. 회고

 자바를 통해 템플릿/콜백 패턴을 적용해보고, JdbcTemplate을 통해 스프링이 제공하는 템플릿/콜백이 적용된 클래스도 사용해보며, 스프링에서 제공하는 클래스를 단순하게 여기고 사용했지만, 아주 정교하게 템플릿화된 클래스들이라는 것을 몸소 느낄 수 있었다.

 필자가 작성하는 코드 중 스프링 프레임워크만을 사용해 구현한 부분도 있었으나, 비지니스 로직을 구현하는 부분도 많았다. 여러번 수정하여 나름의 리펙토링을 거쳤다고 생각했지만, 이제보니 OOP 개념은 전혀 생각하지 않은 메서드 분리 정도의 리펙토링이었을 뿐이었다.

 템플릿/콜백은 스프링이 OOP에 얼마나 가치를 두고 있는지를 잘 보여준다고 한다. 스프링이 제공하는 이러한 클래스와 메서드들을 상황에 맞게 잘 사용하는 것을 물론이고, 직접 코드를 구현할 때에도 여러 디자인 패턴들을 적용하여 활용할 수 있는 것이 자바 개발자의 기본 소양이라고 생각된다.

 추가적으로, 템플릿/콜백 패턴을 도출해내는 과정이 잘 이해되지 않아서 다시한번 정리해보았다. 이 과정에서 어려움을 겪는 분에게 좋은 참고가 되었으면 좋겠다.

https://tlatmsrud.tistory.com/101

 

반응형
반응형

1. 개요

 @Column 옵션 중 nullable = false를 적용하는 순간 ConstraintViolationException: could not execute statement 예외가 발생하였다.


2. 배경

 nullable = false 옵션은 null은 허용하지 않는다는 의미이다. null을 허용할 경우 연관관계가 맺어진 테이블 조회 시 left outer join을 하게되어 성능상 이슈를 가져올 수 있으나, 허용하지 않을 경우 inner join을 하여 성능상 이점을 가져올 수 있기에 해당 옵션을 추가하였다. 그런데 옵션을 추가하자마자 아래와 같은 에러가 발생하였다. null을 허용하지 않는 컬럼에 null이 들어가 SQL을 실행할 수 없다는 에러였다.

 

ConstraintViolationException 예외 발생


3. 원인 분석

 CLASS_ID는 Student 엔티티와 다대일 관계에 있는 Classroom 엔티티의 Join Column이다. 메인 메서드에서 Student 엔티티를 생성한 후 Classroom 설정을 하지 않았는지 확인해보았다.

 

1) main 메서드

 - main 메서드에서는 Student의 수정자 메서드와 classroom의 addStudent 메서드를 통해 양방향으로 주입 가능하도록 메서드를 구현했었다. 어쨌든 student1과 student2는 이를 사용하여 classroom을 set하는 것을 확인하였다. 그후 메서드 구현부분을 차례로 확인해보았다.

	Student student1 = new Student();
        student1.setName("심심심");
        em.persist(student1);

        Student student2 = new Student();
        student2.setName("홍길동");
        em.persist(student2);

        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        em.persist(classroom);

        student1.setClassroom(classroom); // student 1에 classroom set
        classroom.addStudent(student2); // student 2에 classroom set

        List<Student> list = classroom.getStudentList();
        for(Student student : list){
            System.out.println("========================="+student.getName());
        }

 

2) Student.java

@Entity
@Getter
@Setter
public class Student {

    @Id
    @GeneratedValue
    @Column(name = "STUDENT_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "CLASS_ID", nullable = false)
    private Classroom classroom;

    public void setClassroom(Classroom classroom){
        if(this.classroom != null){
            this.classroom.getStudentList().remove(this);
        }
        this.classroom = classroom;
        classroom.getStudentList().add(this);
    }
}

 Student의 경우 setClassroom 시 Student.classroom에 set 함과 동시에 객체 상태의 classroom에도 Student를 반영해주기 위해 classroom.getStudentList().add(this)구문을 추가해주었다. 어쨌든 classroom이 'this.classroom = classroom' 코드에 의해 설정되므로 여기서 null을 발생시키진 않는 것으로 판단하였다.

 

3) Classroom.java

@Entity
@Getter
@Setter
public class Classroom {

    @Id
    @GeneratedValue
    @Column(name = "CLASSROOM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "classroom")
    private List<Student> studentList = new ArrayList<>();

    public void addStudent(Student student){
        if(student.getClassroom() != null){
            student.getClassroom().getStudentList().remove(student);
        }
        student.setClassroom(this);
    }
}

 addStudent 메서드도 studentList에 대한 순수 객체 상태도 엔티티와 동일하게 가져가기 위해 요청으로 들어온 Student가 속한 classroom이 있을 경우, 해당 classroom의 StudentList에서 요청으로 들어온 student를 remove해주는 코드를 넣었다. 중요한 부분은 Student 엔티티의 classroom이 set 되냐 마냐인데 student.setClassroom(this)를 통해 요청으로 들어온 Student 엔티티에도 classroom이 설정되는 것을 확인하였다.

 

 결론은 문제의 원인이 될만한 곳을 발견하지 못했다. 디버깅을 해봐도 student1과 student2 엔티티 모두 classroom을 갖고 있는 것을 확인하였다. 그럼 뭐가 문제일까를 고민하다가 자연스레 JPA의 동작원리를 생각해보았다.

 

 엔티티 매니저의 persist 메서드를 사용하면 엔티티 매니저가 관리하는 영속성 컨텍스트로 엔티티가 관리된다. 동시에 1차 캐시에 엔티티 id와 엔티티가 key, value 형식으로 저장되고 쓰기지연 SQL에 엔티티에 대한 insert 쿼리가 생성되어 쌓인다. 후에 EntityTransaction혹은 EntityManager에 의해 flush() 메서드가 호출되면 쓰기지연 SQL에 있는 쿼리가 실제 DB에서 실행되어 데이터가 저장된다.

 여기서 쓰기지연 SQL에 들어간 insert쿼리가 어떤 순서로 실행됐는지를 확인해보기 위해 실행된 쿼리를 확인해보았다.

예외 발생 쿼리

 그 결과 가장 먼저 실행된 쿼리는 student에 대한 INSERT 쿼리였다. 이 시점에서는 CLASS_ID가 할당되지 않는다. 왜냐하면 student에 classroom을 set 하기 전에 em.persist를 호출했고, 이때 쓰기지연 SQL에는 CLASS_ID가 할당되지 않은 Student의 INSERT 쿼리가 적재되기 때문이다.

 더 나아가면 아래 Student 엔티티에 대한 setClassroom을 하는 시점에 UPDATE 쿼리가 적재되게 된다.

	Student student1 = new Student();
        student1.setName("심심심");
        em.persist(student1);
        
        ..
        
        student1.setClassroom(classroom)

 


4. 해결방안

 student1, 2에 classroom을 set한 이후 em.persist를 호출하는 쪽으로 em.persist 메서드 위치를 변경하였다. 이제 Student에 대한 쓰기지연 SQL에는 classroom이 할당된 상태의 INSERT 쿼리가 나가게 되었다. 

	Student student1 = new Student();
        student1.setName("심심심");
        
        Student student2 = new Student();
        student2.setName("홍길동");
        
        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        em.persist(classroom);

        student1.setClassroom(classroom);
        classroom.addStudent(student2);

        em.persist(student1);
        em.persist(student2);

  

 결론은 em.persist의 순서였는데, 순서로 인한 이런 에러는 추후에도 발생할 여지가 충분히 있다고 생각되었다. 만약 널을 허용하지 않는 옵션이 일반 Column이었다면 실제로 set을 하지 않아 발생한 것이므로 바로 원인을 찾았을 것이다. 하지만 위 케이스의 경우 classroom이라는 부모 엔티티를 수정자 메서드를 통해 설정해줬음에도 불구하고 에러가 발생하기 때문에 원인 찾기가 힘들 수 있다고 생각되었다.

 어쨌든간에 null을 허용하지 않는 자식, 부모 관계에 있는 엔티티를 자식, 부모 순서로 persist 하여 발생하였는데, 부모 엔티티만 persist해도 연관된 엔티티들이 함께 영속상태로 들어가는 영속성 전이를 사용하면 더 깔끔하게 해결 가능하다.

 부모 엔티티의 연관관계 어노테이션에 cascade 옵션을 PERSIST로 설정하면 되며, 자식객체를 persist하는 코드 생략과 앞서 발생했던 문제들을 예방하는 효과를 얻을 수 있었다.

 참고로 영속성 전이는 em.persist()를 실행할 때가 아닌 flush()를 호출할 때 발생한다.

 

1) Classroom.java

@Entity
@Getter
@Setter
public class Classroom {

    @Id
    @GeneratedValue
    @Column(name = "CLASSROOM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "classroom", cascade = CascadeType.PERSIST) // 영속성 전이 옵션
    private List<Student> studentList = new ArrayList<>();

    public void addStudent(Student student){
        if(student.getClassroom() != null){
            student.getClassroom().getStudentList().remove(student);
        }
        student.setClassroom(this);
    }
}

 

2) main 메서드

	Student student1 = new Student();
        student1.setName("심심심");

        Student student2 = new Student();
        student2.setName("홍길동");

        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        
        student1.setClassroom(classroom); // 연관관계 추가
        classroom.addStudent(student2); // 연관관계 추가
        
        em.persist(classroom); // classroom 영속화
        
        tx.commit();

5. 회고

 코드상 원인은 em.persist의 순서였지만, 근본 원인은 JPA에 대한 매커니즘을 생각하지 않고 코드를 작성한 나 자신이었다 :(  JPA는 프레임워크라는 사실을 잊지말자. 스프링도 마찬가지라고 생각하는데 프레임워크를 사용하기 위해서는 매커니즘과 그 성격을 이해하고 사용하는 것이 정말 중요하다고 다시한번 느끼게되었다.

반응형

+ Recent posts