반응형

1. 개요

아키텍처는 여러 가지 방식으로 정의되고 이해될 수 있는 용어다. 가장 단순한 정의는 아래와 같다.

 아키텍처란 어떤 경계 안에 있는 내부 구성요소들이 어떤 책임을 갖고 있고, 어떤 방식으로 서로 관계를 맺고 동작하는지를 규정하는 것이다.

 

아키텍처는 관계와 동작의 규정이므로 많고 다양하다. 그 중 웹 개발에 많이 사용하는 계층형 아키텍처와 오브젝트 중심 아키텍처에 대해 알아보았다.

 


2. 계층형 아키텍처

 책임과 성격이 다른 것을 그룹으로 만들어 분리해두는 것을 아키텍처 차원에서는 계층형 아키텍처라고 한다. 보통 웹 기반의 계층은 데이터 액세스 계층, 서비스 계층, 프레젠테이션 계층, 이 세 개의 계층을 갖는다고 해서 3계층 애플리케이션, 3-tire 애플리케이션 이라고도 한다.

 

 

1) 데이터 액세스 계층(DAO)

 데이터 액세스 계층은 DB, ERP, 레거시 시스템 등에 접근하는 역할을 주로 처리하는 계층이다.

 

2) 서비스 계층

  핵심 비지니스 로직을 처리하는 계층이다. 이 계층의 클래스는 POJO로 작성되기에 객체지향적인 설계 기법이 적용된 코드를 통해 쉽게 테스트하고 유연하게 확장할 수 있다.

 

3) 프레젠테이션 계층

 가장 복잡한 계층이며, 매우 다양한 기술과 프레임워크의 조합을 가질 수 있다. 엔터프라이즈 애플리케이션에서는 HTTP 프로토콜을 사용하는 서블릿이 바탕이 된다.

 


3. 계층형 아키텍처 설계 원칙

 

가장 중요한 설계 원칙은 응집도가 높으면서 다른 계층과는 낮은 결합도를 유지하는 것이다. 각 계층은 자신의 계층의 책임에만 충실해야 한다. 예를들어 아래의 코드는 다른 계층과의 결합도를 갖는 코드이다.

 

public ResultSet findUserByName(String name) throws SQLException;

 

 서비스 계층이 DAO를 호출할 때 ResultSet를 처리해야 한다면, 이는 서비스 계층이 JDBC라는 특정 데이터 액세스 계층 기술에 종속되는 것이다.

 예외도 마찬가지이다. SQLException은 Checked Exception이다. 서비스 계층에서는 이를 무시할 수 없기에 예외 상황을 분석하기 처리하는 코드를 만들어야 한다. 이 코드는 다음과 같이 수정돼야 한다.

 

public List<User> findUserByName(String name) throws DataAccessException;

 User는 사용자 정보를 담는 오프젝트이므로 특정 계층에 종속되지 않는다. 결과는 이렇게 계층에 종속되지 않는 오브젝트로 반환해야 한다.

예외 또한 DataAccessException과 같은 런타임 예외로 만들어야 한다. 이를 통해 서비스 계층에서는 이에 대한 예외처리를 하지 않아도 된다.

 

 이 원칙에 위배되는 흔한 실수 중 하나가 프레젠테이션 계층의 오브젝트를 서비스 계층으로 전달하는 것이다. HttpServeltRequest나 HttpSession과 같은 타입을 서비스 계층 인터페이스 메서드의 파라미터 타입으로 사용하면 안된다.

 웹 방식이 아닌 클라이언트가 이 비지니스 로직을 사용해야 할 경우 재사용이 불가능하며, 단위테스트가 복잡해지는 단점이 있다.

 


4. DB/SQL 중심의 로직 구현 방식

 쉽게 말해서 비지니스 로직을 SQL 을 통해 처리하는 방식이다. 로직이 복잡해지면 SQL이 복잡해지고, 하나의 트랜잭션 단위마다 하나 이상의 SQL이 생성되어야 한다. 이러한 방식은 자바를 DB와 연결해주는 단순한 인터페이스 도구로 전락시키는 것이다.

 


5. 거대한 서비스 계층 방식

 복잡한 SQL을 피하면서, 핵심 비지니스 로직은 서비스 계층에서 처리하는 방식이다. DAO가 응답한 정보를 분석, 가공하는 것이 서비스 계층의 핵심 코드가 된다. 이처럼 어플리케이션 코드에 비지니스 로직이 담겨있기 때문에 자바 언어의 장점을 잘 활용하는 것이고, 테스트하기 용이하다.

 하지만 DAO 계층의 SQL은 서비스 계층의 비지니스 로직의 필요에 따라 만들어지기 쉽기 때문에 강한 결합을 여전히 갖고 있다. 서비스 계층의 코드도 업무 단위로 만들어지므로 DAO를 공유할 수 있는 것을 제외하면 코드의 중복이 발생할 수 있다.

 


6. 오브젝트 중심 아키텍처

 오브젝트를 만들고 오브젝트 구조 안에 정보를 담아서 각 계층 사이에 전달하게 하는 아키텍처 방식이다.

 재사용하기 쉽고, 전 계층에서 일관된 구조를 유지한채 사용할 수 있다.

 

 하지만 최적화된 SQL을 사용하지 못하고, 멤버필드가 많아지면 사용하지 않는 필드가 증가하므로 데이터 중심 아키텍처보다 성능이 떨어질 수 있다. 이러한 문제는 지연 로딩(Lazy Loading)을 통해 어느정도 해결할 수 있으며, 실제로 JPA, 하이버네이트와 같은 ORM 기술에서 지연 로딩을 많이 사용한다.

 

 오브젝트에는 단순히 정보 뿐 아니라 기능도 함께 담고있어야 한다. 그래야 서비스 계층의 비지니스 로직에서 재사용할 수 있기 때문이다.

반응형
반응형

이 글은 '토비의 스프링' 책을 스터디한 후 정리한 내용입니다.

 

1. 스프링의 정의

 토비의 스프링에서는 스프링에 대해 아래와 같이 정의했다.

'자바 엔터프라이즈 개발을 편하게' 해주는 '오픈소스' '경량급' '애플리케이션 프레임워크'

1.1. 애플리케이션 프레임워크

 일반적으로 프레임워크는 특정 업무 분야나 한가지 기술에 특화된 목표를 가지고 만들어진다. 스프링은 특정 계층이나, 기술, 업무 분야에 국한되지 않고 애플리케이션 전 영역을 포괄하는 범용적인 프레임워크이다.

 


1.2. 경량급

 스프링 자체가 가볍다는 뜻이 아니다. 개발환경, 빌드, 테스트 과정, 코드 등 매우 무겁고 복잡했던 EJB에 비해 불필요하게 무겁지 않다는 뜻이다.

 


1.3. 자바 엔터프라이즈 개발을 편하게

 IoC/DI, 서비스 추상화, AOP 등을 통해 로우 레벨의 트랜잭션이나 상태관리, 스레딩, 리소스 풀링과 같은 복잡한 로우레벨의 API를 이해하지 못하더라도 아무 문제 없이 애플리케이션 개발을 할 수 있다.

 


1.4. 오픈소스

 스프링은 오픈소스 프로젝트 방식으로 개발되고 있으며, 2009년 세계적인 IT 기업에 합병되었다. 즉, 전문적이고 성공적인 오픈소스 프레임워크이다.

 


 

2. 복잡한 엔터프라이즈 시스템?

 '자바 엔터프라이즈 개발을 편하게' 라는 부분에서 알 수 있듯이 스프링을 사용하기 전 자바 엔터프라이즈 개발은 불편하고 복잡했다고 한다. 그 이유는 무엇일까?

 


2.1. 기술적인 복잡함

 엔터프라이즈 시스템은 기업과 조직의 업무를 처리해주는 시스템이다. 기업 내 많은 사용자들의 요청을 처리해야 하기에 시스템의 안정성을 고려해야한다. 또한 조직의 업무 처리는 곧, 조직의 핵심 정보 처리이다. 핵심 정보에 대한 보안성도 고려해야 한다. 이 뿐 아니라 업무 자체에도 여러 기술이 사용된다.

 정리하면 안정성과 보안성, 업무적인 기술 등, 다양한 기술적인 복잡도를 갖게 된다.

 


2.2. 비지니스 로직의 복잡함

 기업의 핵심 업무는 복잡하고, 한정되어 있지 않다. 처리하는 업무가 많아짐에 따라 비지니스 로직도 늘어나고, 복잡해진다. 

 


2.3. 기술과 비지니스 로직의 복잡함

 엔터프라이즈 시스템은 기술적인 복잡함과 비지니스 로직의 복잡함이 얽혀있다. 코드를 담는 하나의 파일 안에 여러 기술이 담긴 코드와 비지니스 로직 코드가 얽혀있다고 생각하면 된다.

 


3. 스프링의 목적

 스프링의 목적은 엔터프라이즈 애플리케이션 개발을 편하게 하는 것이다. 스프링은 이를 위해 기술 부분과 비지니스 로직 부분을 분리하고 사용할 수 있도록 도와준다. OOP와 DI를 통해서 말이다.

 사용하는 기술들은 추상화 되어있다. 기술에 대한 로우 레벨의 코드를 이해하고 있지 않아도 사용할 수 있으며, 기술이 변경되어도 비지니스 로직을 수정할 필요가 없게된다.

 AOP를 사용하면 트랜잭션과 같은 기술을 담당하는 부분을 비지니스 로직과 아예 분리할 수도 있다.

 이처럼 스프링은 엔터프라이즈 애플리케이션 개발을 편하게 하도록 돕는다.

 


4. 결론

 스프링은 객체지향이라는 도구를 극대화해서 애플리케이션 개발을 편하게 할 수 있도록 도울 뿐이다. 객체 지향적인 프로그래밍을 얼마나 잘 하냐에 따라 스프링의 활용도가 달라지게 된다.

 때문에 스프링 공부와 함께 개발자 본인의 객체지향 설계와 개발 능력을 키워야 비로소 복잡한 엔터프라이즈 시스템을 잘 개발할 수 있다는 점을 잊지 말자.

 

 

 

반응형
반응형

1. 개요

 AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3개 기반 중 하나이며, 이해하기 어려운 기술 중 하나이다. 스프링에 적용된 가장 인기있는 AOP의 적용 대상은 선언적 트랜잭션 기능이다. 서비스 추상화를 적용한 트랜잭션 경계설정 기능을 AOP를 사용해 더욱 깔끔한 코드로 개선함과 동시에 AOP라는 기술을 이해해보도록 하자.


2. 트랜잭션 코드의 분리

 현재 트랜잰션 관련 코드는 아래와 같이 UserService에 비지니스 로직과 공존한다. 트랜잭션 경계 설정은 비지니스 로직 전/후에 설정되어야 하므로 틀렸다고 할 순 없지만, 트랜잭션 관련 코드와 비지니스 로직 관련 코드가 함께 존재하므로 리팩토링이 필요해 보인다. 트랜잭션 코드 분리를 통해 리팩토링 해보자.

    public void upgradeLevels() {

        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); // 트랜잭션 관련 코드
        }
    }

 

2.1. 메서드 분리

 트랜잭션 경계 설정의 코드와 비지니스 로직 코드 간에는 서로 주고받는 정보가 없다. 이 메서드에서 시작된 트랜잭션 정보는 트랜잭션 동기화 방법을 통해 DAO가 알아서 사용한다. 즉, 이 두 가지 코드는 성격이 다르고, 주고받는 것도 없는 독립적인 코드이므로 아래와 같이 메서드로 분리할 수 있다.

    public void upgradeLevels() {

        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );

        try{
            upgradeLevelsInternal();
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }

    private void upgradeLevelsInternal(){
        List<User> users = userDao.getAll();
        for(User user : users) {
            if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                userLevelUpgradePolicy.upgradeLevel(user);
            }
        }
    }

 

 internal 메서드를 만들어 분리하긴 했지만 여전히 트랜잭션을 담당하는 코드가 UserService에 포함되어 있다. 이번에는 트랜잭션 코드를UserService 밖으로 뽑아내보자.

 

2.2. DI를 통한 트랜잭션 분리

 DI를 사용한다는 것은 인터페이스를 도입한 후 런타임에 구현체 클래스를 설정해주어 확장과 변경을 용이하게 하기 위함이다. 현재 클라이언트는 UserServiceTest이며, UserService에 직접 접근하고 있으나 인터페이스를 도입하면 다음과 같은 형태로 구현 가능하다.

 

인터페이스 도입에 따른 구조

 

 그런데 이 작업의 목표는 트랜잭션 로직을 분리하는 것이다. 이 상태라면 UserServiceImpl에 기존 UserService의 코드가 들어가야 하기에 결국 트랜잭션 로직과 비지니스 로직이 아직도 한 클래스에 들어가 있는 상태이다. 때문에 아래와 같이 트랜잭션 기능을 처리하는 UserService의 구현체 클래스를 새로 생성해야한다.

 

UserServiceTx 추가에 따른 구조

 

 

 키포인트는 UserServiceImpl는 비지니스 로직을, UserServiceTx는 트랜잭션 로직을 담당하게 하는 것이다. 그리고 UserServiceTx 에서 UserService의 구현체를 사용하고 있는데, 이때 사용하는 UserService의 구현체를 UserServiceImpl로 설정하는 것이다. 이런 구조를 채택한 이유는 의존관계를 아래와 같이 설정하여 트랜잭션 로직과 비지니스 로직을 분리하기 위함이다. 이제 코드를 수정하자.

 

새로운 의존관계

 

2.2.1. UserService.java

public interface UserService {

    void upgradeLevels();
    void add(User user);
}

 UserService 인터페이스를 생성한다.

 

2.2.2. UserServiceImpl.java

public class UserServiceImpl implements UserService {

    private IUserDao userDao;

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


    public void setUserLevelUpgradePolicy(UserLevelUpgradePolicy userLevelUpgradePolicy){
        this.userLevelUpgradePolicy = userLevelUpgradePolicy;
    }

    public void upgradeLevels() {
        List<User> users = userDao.getAll(); // DB /
        for(User user : users) {
            if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                userLevelUpgradePolicy.upgradeLevel(user);
            }
        }
    }

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

 UserServiceImpl 클래스를 생성한다. 기존 트랜잭션 관련 코드는 UserServiceTx 클래스에 넣을 예정이므로 제거한다. 그와 동시에 트랜잭션과 관련된 멤버필드인 transactionManager도 제거한다.

 

 

2.2.3. UserServiceTx.java

public class UserServiceTx implements UserService{

    private PlatformTransactionManager transactionManager;
    private UserService userService;

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

    public void setUserService(UserService userService){
        this.userService = userService;
    }

    @Override
    public void upgradeLevels() {
        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );

        try{
            userService.upgradeLevels();
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }

    @Override
    public void add(User user) {
        TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition()
        );
            userService.add(user);
        try{

            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
        }
    }
}

 UserServiceTx 클래스를 생성한다. 각각의 메서드에 트랜잭션 관련 코드를 넣는다. 그리고 비지니스 로직을 담당하는 UserServiceImpl을 DI받기 위해 UserService에 대한 멤버필드와 수정자 메서드를 추가한다.

 

2.2.4. application-context.xml

<bean id = "userService" class ="org.example.user.service.UserServiceTx">
    <property name="transactionManager" ref = "transactionManager"></property>
    <property name="userService" ref = "userServiceImpl"></property>
</bean>

<bean id = "userServiceImpl" class = "org.example.user.service.UserServiceImpl">
    <property name="userDao" ref = "userDao"></property>
    <property name="userLevelUpgradePolicy" ref = "userLevelUpgradePolicy"></property>
</bean>

 이제 DI 정보를 위와같이 수정한다. userService 빈 구현체는 UserServiceTx로 DI하고, UserServiceTx에서 사용하는 userService 멤버필드는 userServiceImpl 빈을 DI한다.

 

2.2.5. 테스트

 이로써 같은 인터페이스에 대한 두 개의 구현체 클래스를 사용하여 비지니스 로직과 트랜잭션 로직을 분리하였다. 이제 작성한 테스트 코드를 위 구조에 맞게 수정한 후 테스트를 통해 확인해보자.

 

2.3. 트랜잭션 코드 분리의 장점

 위와 같은 작업을 통해 얻을 수 있는 장점은 뭘까?

 첫째, 비지니스 로직을 담당하는 코드를 작성할 때에는 트랜잭션 로직에 대해 신경쓰지 않아도 된다. 또 트랜잭션 적용이 필요한지도 신경쓰지 않아도 된다. 트랜잭션은 모두 UserServiceTx와 같은 트랜잭션 관련 클래스가 신경쓸 일이다.

 둘째, 비지니스 로직에 대한 테스트를 쉽게 만들 수 있다. 이에 대한 내용은 아래에서 더 자세하게 다룬다.

 


3. 고립된 단위 테스트

 가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다. 테스트가 실패했을때 원인을 찾기 쉽기 때문이다. 클래스 하나에 대한 테스트와 여러 클래스에 대한 테스트 중 전자가 오류를 찾기 쉽다는건 매우 당연하다.

 

3.1. 복잡한 의존관계 속의 테스트

 UserService를 테스트하기 위해선 아래와 같이 의존하고 있는 클래스인 UserDao, MailSender, PlatformTransactionManager, UserLevelUpgradePolicy를 설정해야 한다. UserServiceTest 클래스는 비지니스 로직인 UserServiceImpl 클래스를 테스트하기 위함이나, 의존 클래스인 UserDao, MailSender, PlatformTransactionManager, UserLevelUpgradePolicy 클래스도 테스트하게 되는 격이다. 왜? 언급한 4개의 클래스 중 한 부분에서 에러가 날 경우 UserServiceTest 의 테스트는 실패하기 때문이다. 

 이럴때 Mock과 같은 테스트 대역을 사용하여 테스트 대상이나 환경이 다른 클래스에 영향을 받지 않도록 고립시켜야 한다.

 

3.2. UserServiceImpl 고립

 현재 필자의 UserServiceImpl은 UserDao와 UserLevelUpgradePolicy를 의존하고 있다. 때문에 이 두 클래스에 대한 테스트 대역인 Mock을 사용하거나 UserDao의 경우 Fake 대역을 사용하는 방법으로 구성할 수 있다. 필자는 Mockito에서 제공하는 MockBean을 사용하여 구현하였다.

 

class UserServiceTest {

    private final UserServiceImpl userServiceImpl = new UserServiceImpl();
    private final IUserDao userDao = mock(IUserDao.class);
    private final UserLevelUpgradePolicy userLevelUpgradePolicy = mock(UserLevelUpgradePolicy.class);
    private final List<User> users = Arrays.asList(
            new User("test1","테스터1","pw1", Level.BASIC, 49, 0, "tlatmsrud@naver.com"),
            new User("test2","테스터2","pw2", Level.BASIC, 50, 0, "tlatmsrud@naver.com"),
            new User("test3","테스터3","pw3", Level.SILVER, 60, 29, "tlatmsrud@naver.com"),
            new User("test4","테스터4","pw4", Level.SILVER, 60, 30, "tlatmsrud@naver.com"),
            new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
   );

    @BeforeEach
    void setUp(){
        userServiceImpl.setUserDao(userDao);
        userServiceImpl.setUserLevelUpgradePolicy(userLevelUpgradePolicy);

        given(userDao.getAll()).willReturn(users);
        willDoNothing().given(userDao).add(any(User.class));

        given(userLevelUpgradePolicy.canUpgradeLevel(users.get(0))).willReturn(false);
        given(userLevelUpgradePolicy.canUpgradeLevel(users.get(1))).willReturn(true);
        given(userLevelUpgradePolicy.canUpgradeLevel(users.get(2))).willReturn(false);
        given(userLevelUpgradePolicy.canUpgradeLevel(users.get(3))).willReturn(true);
        given(userLevelUpgradePolicy.canUpgradeLevel(users.get(4))).willReturn(false);

        given(userLevelUpgradePolicy.upgradeLevel(any(User.class))).will(invocation -> {
           User source = invocation.getArgument(0);
           return source.getId();
        });
    }

    @Test
    @DisplayName("업그레이드 레벨 테스트")
    void upgradeLevels(){

        userServiceImpl.upgradeLevels();

        verify(userDao).getAll();
        verify(userLevelUpgradePolicy,times(5)).canUpgradeLevel(any(User.class));
        verify(userLevelUpgradePolicy).upgradeLevel(users.get(1));
        verify(userLevelUpgradePolicy).upgradeLevel(users.get(3));
    }

    @Test
    @DisplayName("레벨이 할당되지 않은 User 등록")
    void addWithNotAssignLevel(){
        User user = users.get(0);
        user.setLevel(null);

        userServiceImpl.add(user);
        assertThat(user.getLevel()).isEqualTo(Level.BASIC);

        verify(userDao).add(any(User.class));
    }

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

        User user = users.get(0);
        Level userLevel = user.getLevel();

        userServiceImpl.add(user);

        assertThat(user.getLevel()).isEqualTo(userLevel);
    }
}

  given을 통해 Mock에 대한 Stub을 설정하였다. 그리고 userLevelUpgrade가 실제로 이루어졌는지에 대한 테스트코드는 모두 삭제했는데, 그 이유는 해당 테스트는 UserSeviceImpl이 아닌 UserLevelUpgradePolicy에서 이루어져야 하기 때문이다.


4. 단위 테스트와 통합 테스트

 

4.1. 단위테스트

 테스트 대상 클래스를 테스트 대역을 이용해 고립시키는 테스트이다.

 

4.2. 통합 테스트

 두 개 이상의 성격이나 계층이 다른 오브젝트를 연동하여 테스트하거나, 외부 파일, DB 등의 리소스가 참여하는 테스트이다. 스프링 컨텍스트에서 DI된 오브젝트를 테스트하는 것도 통합 테스트이다.

 

4.3. 테스트 선택 가이드라인

 1) 항상 단위 테스트를 먼저 고려한다.

 2) 테스트 코드에서의 의존관계는 모두 차단하고 Stubbing이나 목 오브젝트 등의 테스트 대역을 사용하여 테스트한다.

 3) 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.

 4) DAO의 경우도 Stubbing이나 목 오브젝트로 대처해서 테스트한다.

 5) 여러 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 줄어든다.

 6) 가능하면 스프링의 지원 없이 직접 코드 레벨의 DI를 사용하여 단위테스트를 하는게 좋지만 스프링의 설정 자체도 테스트 대상이고, 스프링을 이용해 좀 더 추상적인 레벨에서 테스트를 해야할 경우는 스프링 테스트 컨텍스트 프레임워크를 이용해 통합 테스트를 작성한다.

 


5. Mockito 프레임워크

 목 클래스를 생성할 필요 없이 메서드 호출만으로 테스트용 목 오브젝트를 구현할 수 있는 프레임워크이다. 이를 통해 생성된 목 오브젝트는 아무 기능이 없기때문에 특정 메서드가 호출됐을 때 어떤 값을 리턴해야하는지와 같은 Stub 기능을 추가해야한다. 이를 Stubbing 이라고 한다.

 애초에 테스트 코드를 Mockito로 작성하고 있던 터라 어렵지 않게 이해할 수 있었다.

반응형
반응형

1. 개요

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

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

 

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

 

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

 


2. 단일 책임 원칙

 

2.1. 단일 책임 원칙이란?

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

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

 

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

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

 

2.2. 단일 책임 원칙의 장점

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

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

 


3. 메일 서비스 추상화

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

 

3.1. DefaultUserLevelUpgradePolicy.java

public class DefaultUserLevelUpgradePolicy implements UserLevelUpgradePolicy{

    ...

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

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

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

        MimeMessage message = new MimeMessage(s);

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

    }
}

 

3.2. 테스트 결과

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

메일 발송 테스트

 

 

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

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

 

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

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

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

 

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

 


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

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

 

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

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

 

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

 

4.1. 코드 리팩토링

4.1.1. DefaultUserLevelUpgradePolicy.java

public class DefaultUserLevelUpgradePolicy implements UserLevelUpgradePolicy{

    private IUserDao userDao;

    private MailSender mailSender;

    private static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

    private static final int MIN_RECOMMEND_FOR_GOLD = 30;

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

    ...
    
    private void sendUpgradeMail(User user) {

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

        mailSender.send(mailMessage);
    }
}

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

 

4.1.2. applicationContext.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id = "dataSource" class ="org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.jdbc.Driver"></property>
        <property name="url" value = "jdbc:mysql://localhost/spring"></property>
        <property name="username" value = "ID"></property>
        <property name="password" value = "PW"></property>
    </bean>

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

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

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

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

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

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

 

4.2. 테스트

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

 

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

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

 

4.3.1. DummyMailSender.java

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

    }

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

    }
}

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

 

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

 

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

 

 

4.3.2. test-applicationContext.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

	...
    
    <bean id = "mailSender" class = "org.example.user.service.DummyMailSender"/>
</beans>

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

 

 

4.3.3. DefaultUserLevelUpgradePolicyTest.java

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

    @Autowired
    IUserDao userDao;

    @Autowired
    DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;

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

    public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

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

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


    }
    @Test
    void canUpgradeLevel() {

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

    }

    @Test
    void upgradeLevel() {

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

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

 

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

추상화 구조 UML

 

 

4.4. 테스트

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

 


5. 테스트 대역

5.1. 테스트 대역이란?

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

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

 

5.2. 테스트 대역의 종류

테스트 대역의 종류

 

 

5.2.1. Dummy

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

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

    }

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

    }
}

 

5.2.2. Fake

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

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

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

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

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

    @Override
    public User get(String id) {

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

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

    ...
}

 

5.2.3. Stub

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

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

public class StubUserDao implements IUserDao{
    
    ...

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

 

 

5.2.4. Spy

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

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

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

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

        ...
    }

 

5.2.5. Mock

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

public class MockMailSender implements MailSender {

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

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

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

    }

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

 

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

 

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

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

 

 

반응형
반응형

1. 개요

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

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


2. 롤백 테스트

 2.1. 테스트 케이스

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

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

 

2.2. 테스트 코드

    @Autowired
    UserService userService;

    @SpyBean
    DefaultUserLevelUpgradePolicy spyUserLevelUpgradePolicy;

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

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

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

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

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

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

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

 

2.3. 테스트 결과

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

 


3. 롤백이 되지 않는 이유

 

3.1. 트랜잭션

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

 

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

 

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

public void upgradeLevels() {

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

 

3.2. JdbcTemplate의 트랜잭션

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

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

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

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

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

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


4. 트랜잭션 동기화

 

4.1. 트랜잭션 동기화란

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

 

4.2. 트랜잭션 동기화 적용

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

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

 

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

 

4.3. 코드 적용

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

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

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

        try{
            c.setAutoCommit(false);

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

            for(User user : users) {

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

 


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

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

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

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

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

 

5.1. PlatformTransactionManager

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

 

5.2. DataSourceTransactionManager 적용

 

5.2.1) UserService.java

public class UserService {

    private IUserDao userDao;


    private PlatformTransactionManager transactionManager;

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

    ...

    public void upgradeLevels() {

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

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

            for(User user : users) {

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

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

 

5.2.2) applicationContext.xml

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

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

...

 

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

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

 

 

 

 

반응형
반응형

1. 개요

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


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

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

더보기

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

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

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

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

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

 

2.1. 상수는 Enum 클래스로

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

 

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

 

2.2. User 필드 추가

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

public class User {

    private String id;
    private String name;
    private String password;
    private Level level;
    private int login;
    private int recommend;

    public User(String id, String name, String password, Level level, int login, int recommend){
        this.id = id;
        this.name = name;
        this.password = password;
        this.level = level;
        this.login = login;
        this.recommend = recommend;
    }
    
    // getter, setter
}

 

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

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

Service를 추가한 UML

 

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

public class UserService {

    private IUserDao userDao;

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

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

}

 

2.4. upgradeLevels() 구현

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

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

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

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

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

public void upgradeLevels(){

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

 

2.5. upgradeLevels() 리팩토링

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

 

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

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

 

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

...

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

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

 

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

    private static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

    private static final int MIN_RECOMMEND_FOR_GOLD = 30;
    
    private boolean canUpgradeLevel(User user){
        Level currentLevel = user.getLevel();

        switch(currentLevel){
            case BASIC: return (user.getLogin() >= MIN_LOGIN_COUNT_FOR_SILVER);
            case SILVER: return (user.getRecommend() >= MIN_RECOMMEND_FOR_GOLD);
            case GOLD: return false;
            default: throw new IllegalArgumentException("Unknown Level :"+currentLevel);
        }
    }
    
    private void upgradeLevel(User user){
	    if(user.getLevel() == Level.BASIC) user.setLevel(Level.SILVER);
	    else if(user.getLevel() == Level.SILVER) user.setLevel(Level.GOLD);
	    userDao.update(user);
    }

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

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

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

 

2.6. upgradeLevel 리팩토링

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

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

public enum Level {

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

    private final int value;
    private final Level nextLevel;

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

 

 

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

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

public class User {

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

 

3) upgradeLevel() 리팩토링

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

// before
    private void upgradeLevel(User user){
	    if(user.getLevel() == Level.BASIC) user.setLevel(Level.SILVER);
	    else if(user.getLevel() == Level.SILVER) user.setLevel(Level.GOLD);
	    userDao.update(user);
    }


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

 

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

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


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

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

더보기

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

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

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

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

 

3.1. UserLevelUpgradePolicy 인터페이스 구현

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

public interface UserLevelUpgradePolicy {
    
    boolean canUpgradeLevel(User user);

    void upgradeLevel(User user);
}

 

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

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

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

public class DefaultUserLevelUpgradePolicy implements UserLevelUpgradePolicy{

    private IUserDao userDao;

    private static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

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

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

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

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

 

 

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

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

public class EventUserLevelUpgradePolicy implements UserLevelUpgradePolicy{

    private IUserDao userDao;


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

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

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

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

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

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

 

3.4. DI 설정 정보 수정

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

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

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

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

        return dataSource;
    }

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

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

 

3.5. 테스트 코드

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

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

 

1) DefaultUserLevelUpgradePolicyTest.java

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

    @Autowired
    IUserDao userDao;

    DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;

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

    public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;

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

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

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


    }
    @Test
    void canUpgradeLevel() {

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

    }

    @Test
    void upgradeLevel() {

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

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

 

2) EventUserLevelUpgradePolicyTest.java

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

    @Autowired
    IUserDao userDao;

    EventUserLevelUpgradePolicy userLevelUpgradePolicy;

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

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

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

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


    }
    @Test
    void canUpgradeLevel() {

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

    }

    @Test
    void upgradeLevel() {

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

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

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

 

3) UserServiceTest.java

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

    @Autowired
    UserService userService;

    @Autowired
    IUserDao userDao;

    @Autowired
    DefaultUserLevelUpgradePolicy defaultUserLevelUpgradePolicy;

    @Autowired
    EventUserLevelUpgradePolicy eventUserLevelUpgradePolicy;

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

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

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

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

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

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

    }

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

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

        userService.add(user);

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

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

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

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

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

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

4. 회고

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

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

반응형
반응형

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. 템플릿이란?

 - 성격이 다른 여러 코드 중에서 중복되거나 일정한 패턴을 가진 부분을 독립시켜서 재사용할 수 있도록 하는 방법이다. 예를들어 DB에서 데이터를 CRUD 하기 위해 여러 메서드들을 생성할 것이다. 이 메서드들은 각기다른 목적과, SQL 쿼리를 갖고 있으나 Connection을 생성하고, 관련 리소스들을 close하는 부분은 공통적으로 들어간다.즉, 중복되는 부분이 있고, 쿼리가 다르다는 패턴을 갖고 있다. 이러한 부분을 독립시켜 재사용할 수 있도록 하는 것이 바로 템플릿이다.

 


2. 초난감 DAO 템플릿

2.1. 초난감 DAO에 예외처리 기능 추가

 - 템플릿 작업 이전에 초난감 DAO에 예외처리 기능을 추가한다. 로직 중간에 예외가 발생했을 때 리소스들이 close 될 수 있도록 하기 위함이다. 일단 deleteAll 메서드만 작성해보도록 하자.

public class UserDao {

    private DataSource dataSource;

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

    public void deleteAll() throws SQLException{

        Connection c = null;
        PreparedStatement ps = null;

        try{
            c = dataSource.getConnection();

            ps = c.prepareStatement("delete from users");

            ps.executeUpdate();

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

            }

        }
    }

 

2.2. 코드의 문제점

 - 모든 메서드에 try, catch, finally 중복코드가 발생하게 된다. 리소스를 해제하는 코드이기에 실수로 하나를 누락한다면 Out Of Memory 가 발생하게 되고, 어플리케이션이 죽을 수 있다. 템플릿 작업을 시작해보자.

 

2.3. 메서드 추출

 - 모든 메서드 내에서 변하지 않는 부분, 혹은 변하는 부분을 메서드로 분리하는 방법이다.

 - 이 메서드들은 변하지 않는 부분이 변하는 부분을 감싸고 있는 구조이므로 변하는 부분을 메서드로 분리하였으나, 이 부분은 다른 코드에서 재사용하기 힘든 부분이기에 비효율적인 방법이다.

public class UserDao {

    ...

    public void deleteAll() throws SQLException{

        Connection c = null;
        PreparedStatement ps = null;

        try{
            c = dataSource.getConnection();

            ps = makeStatement(c);

            ps.executeUpdate();

        }catch(SQLException e){
            e.printStackTrace();
            throw e;
        }finally {
            ...
        }
    }
    
    private PreparedStatement makeStatement(Connection c) throws SQLException{
    	PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}

 

2.4. 템플릿 메서드 패턴

 - 템플릿 메서드 패턴은 변하지 않는 부분은 슈퍼클래스에, 변하는 부분은 추상 메서드로 정의해두고, 서브 클래스에서 오브라이드하여 추상 메서드를 구현하여 쓰도록 하는 패턴이다. 여기서 Connection 생성 부분 및 try, catch, finally 부분은 슈퍼클래스에 구현하고, PreparedStatement를 생성하는 makeStatement() 를 추상 메서드로 정의하여 서브클래스에서 구현하도록 하였다. 서브 클래스 명은UserDaoDeleteAll이다.

public class UserDaoDeleteAll extends UserDao{
    @Override
    PreparedStatement makeStatement(Connection c) throws SQLException {
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}

 - 이제 UserDao의 기능을 확장하고 싶을 때마다 상속을 통해 자유롭게 확장할 수 있으며, 기존 UserDao에 불필요한 변화는 생기지 않는다. OCP 원칙을 지키는 것처럼 보인다. 하지만 템플릿 메서드 패턴은 다음과 같은 한계이 있다.

 첫째, DAO 메서드마다 상속을 통해 새로운 클래스를 만들어야 한다. 현재 UserDao의 메서드가 4개이니 4개의 서브 클래스를 만들어야 한다.

 둘째, 확장구조가 클래스 설계 시점에 고정된다.

 템플릿 메서드 패턴도 비효율적인 방법이다.

 

2.5. 전략 패턴

전략패턴
전략 패턴은 자신의 기능 중 필요에 따라 변경이 필요한 로직을 인터페이스를 통해 외부로 분리시키고, 이를 구현한 구체적인 로직을 필요에 따라 바꿔 사용할 수 있게 하는 디자인 패턴이다.

 

- 템플릿 메서드 패턴보다 유연하고 확장성이 뛰어난 전략패턴을 적용한다. 필요에 따라 변경이 필요한 로직은 PreparedStatement를 생성하는 부분이다. 이 기능을 인터페이스 메서드로 만들어두고, UserDao에서 이를 호출하는 방식을 사용한다.

public interface StatementStrategy {
    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

...

public class DeleteAllStatement implements StatementStrategy{
    @Override
    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}

...

public class UserDao {

    private DataSource dataSource;

    public void setDataSource(DataSource dataSource){
        this.dataSource = dataSource;
    }
    ...
    
    public void deleteAll() throws SQLException{

        Connection c = null;
        PreparedStatement ps = null;

        try{
            c = dataSource.getConnection();

            StatementStrategy strategy = new DeleteAllStatement();
            ps = strategy.makePreparedStatement(c);

            ps.executeUpdate();

        ...
    }
}

- 전략 패턴은 필요에 따라 바꿔 사용해야 하는데, 현재 deleteAll이라는 컨텍스트 안에 구체적인 전략 클래스인 DeleteAllStatement가 고정되어 있다. 컨텍스트가 StatementStrategy 인터페이스 뿐 아니라 구현 클래스도 직접 알고있다는 건 확장에 자유롭지 못하다는 의미이다. 이는 전략 패턴에도, OCP에도 잘 들어맞는다고 볼 수 없다.

 

 전략 패턴에 사용되는 전략은 Client가 결정하는게 일반적이다. Client가 구체적인 전략 하나를 선택하고 오브젝트로 만들어 컨텍스트(UserDao)에 전달하는 것이다. 

 

 현재 UserDao는 Client 부분이 없다. 그렇기에 전략패턴의 일반적인 사용방법에 맞게 Client 부분을 만들어줘야한다. Client 부분은 StatementStrategy의 구현체를 결정 및 생성하는 부분이다.

 중요한 것은 이 컨텍스트에 해당하는 코드를 Client 코드와 분리시켜야 한다. 왜? Client가 전략 하나를 선택하고 전달해야하는 구조여야 하기 때문이다. deleteAll이 Client의 역할을, 그 외의 코드(컨텍스트 코드)는 별도의 메서드로 독립시켜 호출하는 형태로 구현한다.

public class UserDao {

    ...

    public void deleteAll() throws SQLException{
        StatementStrategy strategy = new DeleteAllStatement();
        jdbcContextWithStatementStrategy(strategy);
    }

    ...
    
    public void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException{
        Connection c = null;
        PreparedStatement ps = null;

        try{
            c = dataSource.getConnection();
            
            ps = stmt.makePreparedStatement(c);

            ps.executeUpdate();

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

 

전략 결정 책임을 갖는 부분은 deleteAll() 메서드이므로, 이 메서드가 곧 Client가 된다. 이 메서드는 전략 오브젝트를 만들고 컨텍스트를 호출하는 책임을 지고 있다. 이렇게 Client가 컨텍스트가 사용할 전략을 정해서 전달한다는 면에서 DI 구조라고도 이해할 수 있다. 정확히는 마이크로 DI 구조이다.

 

마이크로 DI
 DI는 다양한 형태로 적용할 수 있다. 일반적인 DI는 의존관계에 있는 두 개의 오브젝트와 이 관계를 설정해주는 오브젝트 팩토리, 이를 사용하는 클라이언트라는 4개의 오브젝트 사이에서 일어난다. 하지만 때로는 클라이언트가 오브젝트 팩토리의 책임을 함께 지고있을 수도 , 클라이언트와 전략이 결합될 수도 있다. IoC 컨테이너 도움 없이 코드 내에서 DI를 적용할 수도 있는데 이를 마이크로 DI라고 한다.

 

2.6. add 메서드 리펙토링

 - 이제 add 메서드를 리팩토링한다. 전략 결정 책임을 갖는 부분은 add() 메서드이며, 이 메서드가 곧 Client가 되도록 한다. 마찬가지로 전략 오브젝트를 만들고 컨텍스트를 호출하도록 하자. 특이사항으로 add 메서드는  요청 User 객체를 받아 처리해야하는 부분이 있다. 이 부분은 AddStatement 생성자에 User 객체를 받아 멤버필드에 주입하도록 한다.

public class AddStatement implements StatementStrategy{

    private User user; // 멤버필드 추가

    public AddStatement(User user){ // makePreparedStatement에서 users를 파라미터로 받지 않고 생성자 파라미터로 설정
        this.user = user;
    }
    @Override
    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        PreparedStatement ps = c.prepareStatement(
                "insert into users(id, name, password) values(?,?,?)");

        ps.setString(1, user.getId());
        ps.setString(2, user.getName());
        ps.setString(3, user.getPassword());
        return ps;
    }
}

...
public class UserDao {
	...
    public void add(User user) throws SQLException {
        StatementStrategy strategy = new AddStatement(user);
        jdbcContextWithStatementStrategy(strategy);
    }
    ...
}

 

 

구현을 하고 나니 문제점이 하나 보인다. 바로 DAO 메서드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다는 점이다. 런타임 시에 DI 해준다는 점을 제외하면 로직마다 상속을 사용하는 템플릿 메서드 패턴보다 크게 나은 점이 없다. 또한 AddStatement의 User 처럼 부가적인 정보가 있는 경우 이를 전달받는 생성자와 인스턴스 변수를 만들어야한다. 이 두 문제를 해결할 수 있는 방법을 알아보자.

 

 

2.7. 로컬 클래스 활용

 - 클래스 파일이 많아지는 문제는 생성할 클래스를 UserDao의 내부 클래스로 구현하는 방법으로 해결할 수 있다. DeleteAllStatement나 AddStatement는 UserDao에서만 사용한다. UserDao와 각 메서드와 강하게 결합되어 있고, 외부에서 사용하지 않는다. 이처럼 특정 메서드에서만 사용되는 것이라면 로컬 클래스로 만들어 사용할 수 있다. 변수처럼 사용하는 개념이라 클래스 파일을 추가로 생성하지 않아도 되며 요청 User에 직접 접근 가능하므로 파라미터도 제거 가능하다.

public void add(User user) throws SQLException {
        class AddStatement implements StatementStrategy{

	// User 파라미터를 받는 생성자 제거
            
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement(
                        "insert into users(id, name, password) values(?,?,?)");

                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                ps.setString(3, user.getPassword());
                return ps;
            }
        }

        StatementStrategy strategy = new AddStatement(); // User 파라미터 제거
        jdbcContextWithStatementStrategy(strategy);
    }

 - 로컬 클래스는 메서드가 종료되면 사라지고, GC에 의해 메모리에서 제거된다.  

 


2.8. 익명 내부 클래스 활용

 - AddStatement 클래스는 add() 메서드에서만 사용할 용도로 만들어졌기에 클래스 이름을 제거하고 클래스를 구현하는 익명 내부 클래스를 적용할 수 있다.

 

익명 내부 클래스
익명 내부 클래스는 이름을 갖지 않는 클래스로, 클래스를 재사용할 필요가 없고, 구현한 인터페이스 타입으로만 사용할 경우 유용하다. 아래와 같은 형태로 만들어 사용한다.

new 인터페이스 이름() { 클래스 본문 };

 

 익명 클래스를 사용하여 ps 클래스 변수를 초기화하였으나, 재사용되는 부분이 없어 jdbcContextWithStatementStrategy의 파라미터 안에 익명 클래스를 사용하였다.

 

  public void add(User user) throws SQLException {
        
        StatementStrategy strategy = new StatementStrategy(){

            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement(
                        "insert into users(id, name, password) values(?,?,?)");

                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                ps.setString(3, user.getPassword());
                return ps;
            }
        };
        
        jdbcContextWithStatementStrategy(strategy);
 }
 
 // ↓ jdbcContextWithStatementStrategy 메서드 내에 익명 클래스 선언
 
 public void add(User user) throws SQLException {
	
        jdbcContextWithStatementStrategy(
            new StatementStrategy(){
                @Override
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                    PreparedStatement ps = c.prepareStatement(
                            "insert into users(id, name, password) values(?,?,?)");

                    ps.setString(1, user.getId());
                    ps.setString(2, user.getName());
                    ps.setString(3, user.getPassword());
                    return ps;
                }
        });
    }

 마찬가지로 deleteAll도 작업을 해주었다.

    public void deleteAll() throws SQLException{
        jdbcContextWithStatementStrategy(new StatementStrategy() {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("delete from users");
                return ps;
            }
        });
    }

 


3. jdbcContextWithStatementStrategy 템플릿

 - 현재 다른 DAO를 생성할 경우 해당 클래스에 jdbcContextWithStatementStrategy 메서드를 생성해야 한다. 결국 이것도 중복되므로 jdbcContextWithStatementStrategy 메서드를 다른 DAO에서 재사용 할 수 있도로 템플릿화 해보자.

 

3.1. 클래스 분리

 - 새로운 클래스 만들고, workWithStatementStrategy 라는 이름으로 기존 로직을 옮겼다.

public class JdbcContext {

    private DataSource dataSource;

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

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

 

- 클래스가 분리됨에 따라 UserDao 코드의 컴파일 에러 부분을 수정한다. 참고로 현재 JdbcContext와 DataSource가 같이 사용되고 있는데, 이는 add, deleteAll 메서드 외에는 jdbcContext를 사용하고 있지 않기 때문이다.

public class UserDao {

    private JdbcContext jdbcContext;

    private DataSource dataSource;

    public void setJdbcContext(JdbcContext jdbcContext){
        this.jdbcContext = jdbcContext;
    }

    public void setDataSource(DataSource dataSource){
        this.dataSource = dataSource;
    }
    public void add(User user) throws SQLException {

        jdbcContext.workWithStatementStrategy(
                new StatementStrategy(){
                    @Override
                    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                        PreparedStatement ps = c.prepareStatement(
                                "insert into users(id, name, password) values(?,?,?)");

                        ps.setString(1, user.getId());
                        ps.setString(2, user.getName());
                        ps.setString(3, user.getPassword());
                        return ps;
                    }
                });

    }

    public void deleteAll() throws SQLException{
        jdbcContext.workWithStatementStrategy(new StatementStrategy() {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("delete from users");
                return ps;
            }
        });
    }
	...
}

 

 

 - JdbcContext가 DataSource를 의존하도록 하고, UserDao가 JdbcContext를 추가로 의존하도록 DI 정보를 수정한다.

@Configuration
public class DaoFactory {
    @Bean
    public UserDao userDao(){
        UserDao userDao = new UserDao();
        userDao.setJdbcContext(jdbcContext());
        userDao.setDataSource(dataSource());
        return userDao;
    }

    @Bean
    public JdbcContext jdbcContext(){
        JdbcContext jdbcContext = new JdbcContext();
        jdbcContext.setDataSource(dataSource());
        return jdbcContext;
    }

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

        dataSource.setDriverClass(com.mysql.jdbc.Driver.class);
        dataSource.setUrl("jdbc:mysql://localhost/spring");
        dataSource.setUsername("tlatmsrud");
        dataSource.setPassword("tla1203#");

        return dataSource;
    }
}

 

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 = "tlatmsrud"></property>
        <property name="password" value = "tla1203#"></property>
    </bean>

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

    <bean id = "jdbcContext" class = "org.example.user.dao.JdbcContext">
        <property name = "dataSource" ref = "dataSource"></property>
    </bean>
</beans>

- 이로써 JdbcContext를 UserDao로부터 완전히 분리하고 DI를 통해 연결될 수 있도록 설정을 마쳤다. 이 방법은 스프링 IoC 컨테이너를 통한 DI 방법인데, 이 방법 외에 UserDao에서 직접 DI하는 방법도 있다.

 

3.2. UserDao 내부 직접 DI

 - JdbcContext를 스프링 빈으로 등록해서 UserDao에 DI 하는 대신 UserDao 내부에서 직접 DI 할 수 있다. JdbcContext 객체를 UserDao의 dataSource 수정자 메서드 내에서 생성 후 생성된 JdbcContext의 수정자 메서드를 사용해 dataSource를 DI하는 방식이다.

 

1) jdbcContext 빈 제거

<?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 = "tlatmsrud"></property>
        <property name="password" value = "tla1203#"></property>
    </bean>

    <bean id = "userDao" class = "org.example.user.dao.UserDao">
        <property name = "dataSource" ref = "dataSource"></property>
    </bean>
    
    // jdbcContext 빈 제거
</beans>

 

2) UserDao 수정

public class UserDao {

    private JdbcContext jdbcContext;

    private DataSource dataSource;

    public void setDataSource(DataSource dataSource){
        this.dataSource = dataSource; // 아직 JdbcContext를 적용하지 않는 나머지 메서드를 위해 보존
        jdbcContext = new JdbcContext(); // JdbcContext 객체 생성 (IoC)
        jdbcContext.setDataSource(dataSource); // dataSource DI
    }
   ...
}

 - setDataSource() 메서드는 DI 컨테이너가 DataSource 오브젝트를 주입해줄 때 호출된다. 이때 JdbcContext에 대한 수동 DI 작업(마이크로 DI)을 진행하는 것이다. 이 방법은 인터페이스를 두지 않아도 될 만큼 긴밀한 관계를 갖는 클래스(DAO와 JdbcContext)들을 굳이 빈으로 분리하고 싶지 않을 때 사용한다. 하지만 JdbcContext를 싱글톤으로 만들 수 없고, DI 작업을 위한 부가적인 코드가 필요하다는 단점이 있다.

 

 직접 DI 하는 방법과 DI 컨테이너를 통해 DI하는 방법 중 어떤 방법이 더 낫다고 할 수 없다. 상황에 맞게 사용하면 된다. 긴말한 관계를 갖는 클래스이거나, 외부 설정 파일에 DI 정보를 노출시키지 않고 싶다면 직접 DI를 사용하고, 그렇지 않다면 컨테이너를 통한 DI를 하면 된다.


4. 회고

 템플릿의 개념을 예제 코드를 통해 알아보았는데, 여러 패턴들과 개념들이 짬뽕되어있어 이해하는데 어려움있었다. 하지만 역시 인간은 적응의 동물인건가... 반복해서 읽고 두드리다보니 내용이 머릿속에 정리되더라.

 처음엔 템플릿이 리팩토링과 비슷하다는 느낌을 많이 받았는데, 리팩토링은 코드를 개선한다는 느낌이 강하고, 템플릿은 코드를 설계한다는 느낌이 강하게 들었다. '템플릿화를 통해 좋은 코드 구조를 설계하고, 이후 내부 로직을 리펙토링한다.' 라고 분리되어 생각됐다.

 템플릿은 개발자마다 다르기에 어떤 방법이 낫다고 설명할 수 없다고 한다. 다만 본인이 만들었던 인터페이스, 클래스들을 본인이 분석한 내용을 바탕으로 명확한 근거와 분명한 이유를 알고 있는게 중요하다는 토비님의 말씀에 오늘도 참회의 눈물을 흘린다.

 

 

반응형
반응형

1. 개요

  - 토비의 스프링 4주차 스터디

  - 2장 테스트 (145p ~ 182p)

 


2. 테스트

 - 테스트란 개발자가 의도했던 대로 코드가 정확히 동작하는지를 확인하고, 코드에 대한 확신을 얻기 위한 작업이다. 보다 정확한 테스트를 위해서는 하나의 테스트에 여러 책임을 갖게 하지 않도록 관심사를 분리해야한다. 한꺼번에 많은 기능을 테스트하면 그 과정도 복잡해지고, 오류가 발생했을 때 원인을 찾기 힘들기 때문이다. 이렇게 한 가지 관심에 집중할 수 있게 작은 단위로 만드는 테스트를 단위 테스트(Unit Test)라고 한다.

 

2.1. UserDaoTest의 문제점

public class UserDaoTest {

    public static void main(String[] args) throws SQLException {
       
        ApplicationContext applicationContext = new GenericXmlApplicationContext("applicationContext.xml");
        UserDao userDao = applicationContext.getBean("userDao", UserDao.class);

        User user = new User();
        user.setId("test");
        user.setPassword("1234");
        user.setName("테스터");
        userDao.add(user);

        User findUser = userDao.get("test");
        
        System.out.println(findUser.getName);
        
    }
}

 

1) 수동 확인 작업의 번거로움

 - 콘솔에 표시되는 findUser.getName값이 의도한 값으로 출력됐는지를  개발자가 확인해야하는 수동적인 작업이 불가피하다. 만약 검증해야할 클래스가 많아진다면, 수동 확인 작업의 양도 증가할 것이다.

 

2) 실행 작업의 번거로움

 - 테스트할 클래스를 개발자가 일일이 실행해야 한다. 마찬가지로 검증해야할 클래스가 많아진다면, 실행 작업의 양도 증가할 것이다.

 

이 중 수동 확인 작업의 번거로움을 줄이기 위한 방법이 있다. 다음과 같이 콘솔에 DB 조회 결과를 뿌려주는게 아니라, 테스트에 대한 결과를 뿌려주는 방법이다.

public class UserDaoTest {

    public static void main(String[] args) throws SQLException {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("applicationContext.xml");
        UserDao userDao = applicationContext.getBean("userDao", UserDao.class);

        User user = new User();
        user.setId("test");
        user.setPassword("1234");
        user.setName("테스터1");
        userDao.add(user);

        User findUser = userDao.get("test");
        if(!findUser.getName().equals(user.getName())){
            System.out.print("테스트 실패 (name)");
        }else if(!findUser.getPassword().equals(user.getPassword())){
            System.out.println("테스트 실패 (password)");
        }else{
            System.out.println("조회 테스트 성공");
        }

    }
}

 

하지만 좀 더 편리하게 테스트를 수행하고 결과를 확인하려면 단순 main 메서드로는 한계가 있다. 테스트 코드를 구현하는 일정한 패턴도 없고, 그 결과를 종합적으로 확인하기도 힘들다.

 그래서 일정한 패턴을 가진 테스트를 만들 수 있고, 많은 테스트를 간단히 실행시킬 수 있으며, 테스트 결과를 종합해 볼 수 있고, 실패 지점을 빨리 찾을 수 있는 자바 테스트 프레임워크인 JUnit이 등장했다.

 

2.2. JUnit 전환 

 예제에서는 JUnit4를 사용하였으나, 필자의 경우 JUnit5를 사용하였다. 문법만 다르고 테스트 코드를 작성하는 매커니즘은 동일하기 때문에 어렵지 않게 실습할 수 있었다. 중요한 내용들은 다시한번 정리하며 기록해보았다.

 

1) 테스트 코드는 순서를 보장하지 않는다.

 - 현재 addAndGet(), count(), getUserWithInvalidId() 순서이나, 실제 테스트 코드가 실행되는 순서는 실행때마다 뒤바뀔 수 있다. 때문에 테스트 메서드간 영향을 받지 않도록 구현해야 한다.

 

2) 부정적인 테스트 케이스도 만들어야 한다.

 -  코드에서 발생할 수 있는 다양한 상황입력 값을 고려하는 테스트를 만들기 위해서는 부정적인 테스트 케이스(네거티브 테스트)도 만들어야 한다. 정상적인 상황보다 예외적인 상황에서 버그가 발생할 확률이 높기 때문이다.

 

유저를 등록하고 조회하는 addAndGet(), 카운트를 조회하는 count(), 유효하지 않는 아이디를 조회했을 때 예외를 떨구는 네거티브 케이스인 getUserWithInvalidId() 를 작성하였다.

 이 책에서는 테스트의 개념 설명이 주 목적이기에 Mock이나 Stubbing과 같은 개념이 사용되지 않아 관심사를 더 분리하진 않은 것 같다.

class UserDaoTest {

    private UserDao userDao;
    private User user1;
    private User user2;
    private User user3;

    @BeforeEach
    void setUp(){
        ApplicationContext applicationContext = new GenericXmlApplicationContext("applicationContext.xml");
        userDao = applicationContext.getBean("userDao", UserDao.class);
        user1 = new User("test1","1234","테스터1");
        user2 = new User("test2","12345","테스터2");
        user3 = new User("test3","123456","테스터3");
    }
    @Test
    public void addAndGet() throws SQLException{

        userDao.deleteAll();
        assertThat(userDao.getCount()).isEqualTo(0);

        userDao.add(user1);
        userDao.add(user2);
        assertThat(userDao.getCount()).isEqualTo(2);

        User findUser1 = userDao.get("test1");
        assertThat(findUser1.getName()).isEqualTo(user1.getName());
        assertThat(findUser1.getPassword()).isEqualTo(user1.getPassword());

        User findUser2 = userDao.get("test2");
        assertThat(findUser2.getName()).isEqualTo(user2.getName());
        assertThat(findUser2.getPassword()).isEqualTo(user2.getPassword());
    }

    @Test
    public void count() throws SQLException{

        userDao.deleteAll();
        assertThat(userDao.getCount()).isEqualTo(0);

        userDao.add(user1);
        userDao.add(user2);
        userDao.add(user3);

        assertThat(userDao.getCount()).isEqualTo(3);
    }

    @Test
    public void getUserWithInvalidId() throws SQLException {

        userDao.deleteAll();
        assertThat(userDao.getCount()).isEqualTo(0);

        assertThatThrownBy(() -> userDao.get("test1")).isInstanceOf(EmptyResultDataAccessException.class);
    }
}

 

 


3. TDD

 - TDD는 Test Driven Development의 약자로 테스트 주도개발을 말한다. 쉽게 말하면 테스트 코드를 먼저 개발하고, 어플리케이션 코드를 작성하는 것이다.

 

3.1. TDD의 장점

1) 자연스러운 객체 지향 설계.

  - 테스트 코드 작성 시 관심사를 분리하여 작은 단위로 모듈화시켜야 하는데, 이 과정에서 다른 기능과의 결합도를 낮추고, 한 기능에만 집중하도록 응집도를 높이는 코드를 만들기 위해 노력하게 된다. 즉, 자연스러운 객체 지향적인 설계를 유도한다.

 

2) 유지보수 용이.

 - 유지보수 도중 코드 수정이 발생할 경우 필연적으로 테스트를 해야하는데, 이를 테스트 코드 실행만으로 확인 가능하다. 또한, 코드를 수정했을 때 파생될 수 있는 다른 부분의 에러나 사이드 이펙트을 바로 체크할 수 있어 유지보수에 용이하다.

 

3) 테스트 문서의 기능.

 - 테스트 코드의 결과만으로도 테스트 문서를 대체할 수 있으며, 기존 통합 테스트보다 더 신뢰성 있는 정보가 될 수 있다.

 

3.2. TDD의 단점

1) 생산성 저하

 - 어플리케이션 코드와 테스트코드를 모두 작성해야 한다면, 소요되는 시간도 많아진다. 이는 곧 생산성 저하로 이어진다. (현실적으로 테스트 코드를 고려한 개발 기간도 주어지지 않는다.)

 


4. 회고

 올해 초 TDD 멘토링 프로그램에 참여하였기에 전반적인 내용들을 쉽게 이해할 수 있었다. 그때 배웠던 내용들을 생각하며 한번 더 정리하게 된 좋은 시간이었다.

반응형
반응형

1. 개요

  - 토비의 스프링 3주차 스터디

  - 1장 오브젝트와 의존관계 (123p ~ 143p)

 


2. 메서드를 이용한 의존관계 주입

 

2.1. 수정자 메서드를 통한 주입

 지금까지는 UserDao 의존성 주입을 위해 생성자를 사용했으나, 자바 관례에서는 수정자를 사용한다.

수정자란 Setter 메서드로 오브젝트 내의 애트리뷰트 값을 변경하는 것이 목적이며, 입력 값에 대한 검증 작업을 수행할 수도 있다.

public class UserDao{

    private ConnectionMaker connectionMaker;
    
    public void setConnectionMaker(ConnectionMaker connectionMaker){
    	this.connectionMaker = connectionMaker
    }
    
    ...
}

 

2.2. 일반 메서드를 통한 주입

 수정자 메서드는 관례에 따라 set으로 시작하고 한번에 하나의 파라미터만 가질 수 있다. 이런 제약이 싫다면 여러개의 파라미터를 갖는 일반 메서드를 DI용으로 사용할 수 있으나, 일반적으로 수정자 메서드를 사용한다.

 

 

2.3. 수정자 메서드를 선호하는 이유

 1) 자바 빈 규약을 준수 (멤버변수에 getter/setter 메서드가 있어야 한다)

 2) XML을 사용한 DI 처리 시 수정자 메서드와 연관되는 부분이 있다.

 

 XML을 사용한 DI부분을 보면 위 내용을 이해할 수 있을 것이다.


3. XML을 사용한 DI

 의존성 주입을 하는 방법에는 DaoFactory 클래스 처럼 Java 코드 구현하는 방법과 XML 파일로 구현하는 방법이 있다. XML은 단순한 텍스트 파일이기 때문에 다루기 쉽고, 쉽게 이해할 수 있으며, 별도의 빌드 작업이 없다는 것이 장점이 있다.

 

3.1. XML 설정

 XML 설정 시 <beans> 를 루트 엘리먼트로 하며, 하위에 여러 개의 <bean> 태그를 통해 bean들을 정의한다. <beans>를 DaoFactory의 @Configuration, <bean>을 @Bean으로 대응해서 생각하면 된다. <bean> 태그는 id와 class라는 속성을 갖는데 id는 빈의 이름, class는 빈의 클래스 경로를 넣어준다.

<bean id = "connectionMaker" class = "org.example.user.factory.DConnectionMaker"/>

 

 의존관계 설정은 <bean>의 <property> 태그를 사용한다. <property> 태그는 name과 ref라는 속성을 갖는데 name은 빈 오브젝트의 멤버변수 이름을, ref는 수정자 메서드를 통해 주입해줄 빈의 이름이다.

 

name에 입력하는 값에는 조건이 하나가 붙는데 바로 name에 대한 수정자 메서드가 있어야 한다는 점이다. 이 조건이 붙는 이유는 XML을 통한 DI시 객체 의존 관계 설정을 수정자 메서드로 처리하기 때문이다.

이 부분이 앞서 언급했던 'DI 처리 시 수정자 메서드와 매핑되는 부분'이다.

그리고 name 멤버 변수에 대한 수정자 메서드(setter)가 있어야 한다는 것이 자바 빈 규약 중 하나이다.

이에 따라 UserDao의 생성자 메서드를 통해 의존 객체를 받아오던 방식을 수정자 메서드를 통한 방식으로 변경하였다.

public class UserDao{

    private ConnectionMaker connectionMaker;
    
    public void setConnectionMaker(ConnectionMaker connectionMaker){
    	this.connectionMaker = connectionMaker
    }
    
    ...
}

 

이 내용을 바탕으로 DaoFactory를 수정하면 아래와 같으며,

@Configuration
public class DaoFactory {
    @Bean
    public UserDao userDao(){
        UserDao userDao = new UserDao();
        userDao.setConnectionMaker(connectionMaker());
        return userDao;
    }
    
    @Bean
    public ConnectionMaker connectionMaker(){
        return new DConnectionMaker();
    }
}

 

DaoFactory를 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 = "userDao" class ="org.example.user.dao.UserDao">
        <property name="connectionMaker" ref = "connectionMaker"></property>
    </bean>

    <bean id = "connectionMaker" class = "org.example.user.factory.DConnectionMaker"/>
</beans>

 

만약 name에 입력한 멤버변수의 수정자 메서드가 없다면, XML을 통한 의존 주입 시 name 부분에서 수정자를 만들라는 메시지와 함께 오류가 발생한다.

connectionMaker의 setConnectionMaker 메서드 주석 처리 시 발생하는 오류

 

3.2. XML을 사용하는 애플리케이션 컨텍스트

 이제 애플리케이션 컨텍스트 생성 시 DaoFactory 설정파일을 읽는 방법 대신 XML 설정파일을 읽는 방법으로 변경해야 한다. 방법은 GenericXmlApplicationContext 생성자를 사용하고, 생성자 파라미터로 XML파일의 클래스 패스를 지정하면 된다.

 XML 설정파일의 이름은 관례에 따라 applicationContext.xml이라고 만든 후 앞 내용을 참고하여 XML 파일을 작성한다.

 작성이 완료되면 main 메서드에서 AnnotationConfigApplicationContext 생성자를 통한 ApplicationContext 부분을 주석처리한 후 GenericXmlApplicationContext으로 변경한다. 

    public static void main(String[] args) throws SQLException, ClassNotFoundException {
        
        ApplicationContext applicationContext = new GenericXmlApplicationContext("applicationContext.xml");
        // ApplicationContext applicationContext = new AnnotationConfigApplicationContext(DaoFactory.class);
        UserDao userDao = applicationContext.getBean("userDao", UserDao.class);

        User user = new User();
        user.setId("test123");
        user.setPassword("1234");
        user.setName("테스터");
        userDao.add(user);
    }

XML 설정파일을 통한  DI가 처리되어 테이블에 데이터가 Insert 됨을 확인할 수 있다.

main 메서드 실행 결과

 


4. DataSource 사용하기

 지금은 DB Connection을 ConnectionMaker.makeConection() 메서드를 통해 가져오고 있지만, 자바에서 지원하는 DataSource라는 인터페이스를 사용하여 가져오도록 수정해보자.

 DataSource 의 구현체 클래스는 SimpleDriverDataSource을 사용한다. 스프링에서 제공하므로 라이브러리 의존성을 추가해주자.

implementation 'org.springframework:spring-jdbc:3.1.4.RELEASE'

 

 

4.1. 사용하지 않는 코드 삭제 

ConnectionMaker 대신 DataSource를 사용하고, DConnectionMaker, NConnectionMaker 대신 SimpleDriverDataSource를 사용하기 때문에 ConnectionMaker, DConnectionMaker, NConnectionMaker 클래스를 삭제하자.

 

4.2. UserDao 코드 수정

 DataSource에 대한 멤버변수를 생성하고, XML 설정을 사용하기 위해 DataSource에 대한 수정자 메서드를 생성한다. 기존 사용했던 conectionMaker.makeConnection() 대신 dataSource.getConnection()으로 변경한다.

public class UserDao {

    private DataSource dataSource;

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

    public void add(User user) throws SQLException {

        Connection c = dataSource.getConnection();

        ...
    }

    public User get(String id) throws SQLException{

        Connection c = dataSource.getConnection();

       ...
    }
}

 

4.3. DaoFactory 수정

 XML을 통해 DI를 처리하긴 하지만 Java 코드 부분도 한번 수정해보았다. SimpleDriverDataSource의 사용 방법은 다음과 같이 수정자 메서드를 통해 DriverClass, Url, Username, Password를 설정한 후 해당 객체를 DataSource 형으로 리턴하면 된다.

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

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

        dataSource.setDriverClass(com.mysql.jdbc.Driver.class);
        dataSource.setUrl("jdbc:mysql://localhost/spring");
        dataSource.setUsername("DB 접속 ID");
        dataSource.setPassword("DB 접속 Password");

        return dataSource;
    }
}

 

4.4. XML 파일 수정

 XML 파일의 DI 정보를 다음과 같이 수정한다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id = "dataSource" class ="org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.jdbc.Driver"></property>
        <property name="url" value = "jdbc:mysql://localhost/spring"></property>
        <property name="username" value = "DB 접속 ID"></property>
        <property name="password" value = "DB 접속 Password"></property>
    </bean>

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

여기서 <property> 속성으로 value라는 값이 처음 사용된다. value는 name에 대한 멤버변수의 을 주입하는 속성이며, 수정자 메서드의 파라미터에 맞게 자동으로 변환하여 주입한다.

 

예를들어 driverClass의 값을 주입할 때 "com.mysql.jdbc.Driver"라는 문자열을 넣고 있는데, 내부적으로 동작할 때에는setDriverClass 수정자 메서드의 파라미터 자료형에 맞게 문자열을 클래스로 변환하는 중간과정을 거치게 된다. 최종적으로는 문자열이 아닌 클래스 형태로 변환되어 수정자 메서드를 호출하게 된다. 

<property name="driverClass" value="com.mysql.jdbc.Driver"></property>

5. 회고

 XML을 통한 DI 방식을 사용해보았다. 실무에서 담당했던 프로젝트 중 대부분은 XML을 통한 DI가 사용되고 있었는데, 실제로 어떻게 DI되는지는 생각해보지 않았었다.

 이번 스터디를 통해 자바 코드에 수정자 메서드를 왜 넣었는지와 값 주입 시 아무 생각없이 문자열을 넣을 수 있었던 이유를 실제 동작 메커니즘과 함께 이해하게 되었다.

 최근에는 DI를 담당하는 설정파일 없이 @Autowired나 final을 사용하여 생성자를 통한 DI를 하고 있어 이러한 메커니즘을 굳이 이해할 필요가 있을까라는 생각이 들 수 있으나, 실무에서는 오래된 서비스를 운용할 케이스도 있으므로 충분히 이해하고 넘어가야할 가치가 있는 부분이라고 생각된다.

 

반응형

+ Recent posts