반응형

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를 하고 있어 이러한 메커니즘을 굳이 이해할 필요가 있을까라는 생각이 들 수 있으나, 실무에서는 오래된 서비스를 운용할 케이스도 있으므로 충분히 이해하고 넘어가야할 가치가 있는 부분이라고 생각된다.

 

반응형
반응형

1. 개요

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

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

 


2. 싱글톤 레지스트리

 오브젝트와 관계를 설정하고 리턴하는 순수 자바코드 형태의 '오브젝트 팩토리'와 '어플리케이션 컨텍스트'는 하나의 큰 차이가 있다. 어플리케이션 컨텍스트는 빈을 모두 '싱글톤'으로 만든다는 점이다.

 아래 예제를 보면 알 수 있듯이 애플리케이션 컨텍스트에서 조회한 userDao에 대해 getBean을 두 번 호출할 경우 모두 동일한 객체가 리턴되었고, DaoFactory에서 userDao 메서드를 호출할 경우 다른 객체를 리턴하고 있다. (DaoFactory 관련 코드는 1주차 게시글에 기재되어 있음)

ApplicationContext applicationContext = new AnnotationConfigApplicationContext(DaoFactory.class);
UserDao userDao1 = applicationContext.getBean("userDao", UserDao.class);
UserDao userDao2 = applicationContext.getBean("userDao", UserDao.class);
System.out.println("동일한 객체인가 : " + (userDao1 == userDao2)); // true

DaoFactory daoFactory = new DaoFactory();
UserDao factoryUserDao1 = daoFactory.userDao();
UserDao factoryUserDao2 = daoFactory.userDao();
System.out.println("동일한 객체인가 : " + (factoryUserDao1 == factoryUserDao2)); // false

 

 그렇다면 스프링 애플리케이션 컨텍스트는 왜 객체를 싱글톤으로 생성할까?


3. 싱글톤으로 빈을 생성하는 이유

 서버환경에서는 객체를 싱글톤으로 만드는게 성능적으로 좋기 때문이다. 예를들어 클라이언트에서 요청이 올 때마다 관련 오브젝트를 새로 생성한다고 생각해보자. 한 요청에 5개의 오브젝트가 생성되어야 한다면, 100번의 요청에는 500개의 오브젝트가 생성된다. 생성 후 사용되지 않는 객체는 자바의 GC에 의해 처리되는데, 처리해야할 객체가 많으니 GC 횟수가 많아져 서버 리소스가 자주 사용될 것이고, 이는 곧 서버 부하를 가져오게 된다.

 만약 싱글톤으로 빈을 생성한다면, 한 요청에 5개의 오브젝트를 생성하는게 아닌 공유하게 될 것이고, GC로 인한 부하도 발생하지 않을 것이다. 이처럼 오브젝트를 싱글톤으로 생성하고 관리하는 것을 '싱글톤 레지스트리'라고 하며, 애플리케이션 컨텍스트는 싱글톤 레지스트리의 역할도 수행한다고 할 수 있다.

 

3.1. 멀티 스레드 환경에서의 싱글톤

 멀티 스레드 환경에서는 하나의 싱글톤 객체에 여러 스레드가 접근하게 된다. 그렇기에 싱글톤 객체의 상태 정보를 수정하는 케이스는 절대 있어서는 안된다. 상태정보는 일반적으로 오브젝트 내 비지니스 로직과 관련되어 사용된다. 상태 정보가 수정된다면 비지니스 로직의 결과 값이 바뀔 수 있다는 뜻인데, 멀티 스레드 환경에서는 상태 값이 덮어 씌워지게 된다. 즉 스레드 각각이 원하지 않는 값을 읽어 올 수 있다는 뜻이다.

 그렇기에 싱글톤은 인스턴스 필드 값을 변경하고 유지하는 상태가 아닌 무상태(stateless) 방식으로 만들어야 한다. 단, 읽기 전용의 상태 값이라면 덮어 씌워질 위험이 없으므로 상태 방식으로 만들어도 상관없다.

 

3.2. 스프링 빈의 스코프

 스프링이 관리하는 빈이 생성되고, 존재하고, 적용하는 범위를 빈의 스코프라고 한다. 빈의 스코프는 일반적으로 싱글톤이다. 하지만 경우에 따라 싱글톤 이외의 스코프를 가질 수 있다. 이에 대해선 추후 10장에서 자세히 다룰 예정이라고 한다.


4. 의존관계 주입(DI)

 IoC는 스프링만의 용어가 아니다. 1주차에서 공부했듯이 IoC라는 개념은 소프트웨어에서 자주 발견할 수 있는 일반적인 개념이다. 객체지향적인 설계를 할 경우 자연스럽게 IoC를 적용할 수 있다. 때문에 스프링을 IoC 컨테이너라고 하기엔 타 소프트웨어와 확실히 구분되지 않았다. 이러한 이유로 스프링 IoC 방식의 핵심 기능을 나타내는 의존 관계 주입(Dependency Injection)이라는 용어가 만들어져 사용되기 시작했다.

 스프링의 동작 원리는 IoC라고 할 수 있지만, 타 프레임워크와 차별화되서 제공되는 핵심 기능은 의존 관계 주입이라고 할 수 있기 때문이다. 그렇다면 의존관계란 뭘까?


5. 의존관계

 두 개의 클래스가 의존관계에 있다는 것을 설명할 때에는 방향성도 같이 설명해야한다. 즉, 누가 누구에게 의존하는 관계에 있다는 식이어야 한다는 말이다. UML 모델에서는 두 클래스의 의존관계를 점선으로 표시하며, 아래 그림을 'A가 B에 의존한다'라고 한다.

 

A가 B에 의존한다.

 

 의존한다는 건 대상이 변하면 자신에게 영향을 미친다는 뜻이다. 여기서는 B가 변하면 A에 영향을 미친다. B의 기능이 추가되거나, 변경되거나, 형식이 바뀌면 그 영향이 A로 전달되기 때문이다.

 예를들어 A가 B를 사용하는 경우 B에 정의된 메소드를 호출해서 사용하게 되는데, B의 메소드 형식이 바뀌면 A도 그에 따라 수정해야하고, B의 형식은 그대로지만 기능이 바뀔경우 A의 기능 수행에도 영향을 미칠 수 있다. 반대로 B는 A에 영향을 받지 않지 않는다. B에서 A를 사용하지 않기 때문이다. 일반적으로 이렇게 사용의 관계에 있는 경우 의존 관계가 맺어진다.

 

5.1. UserDao의 의존관계

 지금까지 작업했던 UserDao는 ConnectionMaker에 의존하고 있다. ConnectionMaker 인터페이스가 변한다면 그 영향을 UserDao가 받을 수 있다. 하지만, ConnectionMaker 인터페이스를 구현한 클래스, 즉 DConnectionMaker가 다른 것으로 바뀌어도 UserDao에 영향을 주지 않는다. 인터페이스를 사용했기 때문에 구현 클래스와의 관계가 느슨해졌기 때문이다.

public class UserDao {

    private ConnectionMaker connectionMaker;

    public UserDao(ConnectionMaker connectionMaker){
        this.connectionMaker = connectionMaker;
    }
    
    ...
}

UserDao, ConnectionMaker, DConnectionMaker의 관계

 

 위 그림에서 알 수 있듯이 UserDao 클래스는 ConnectionMaker 인터페이스에게만 의존한다. UserDao 입장에서 DConnectionMaker 클래스의 존재도 알지 못하기에 이 클래스에는 의존하지 않는다.

 이처럼 인터페이스를 통한 의존관계를 갖는 경우에는 UserDao가 본인이 사용할 오브젝트가 어떤 클래스로 만든것인지 미리 알 수 없다. DaoFactory와 같이 DI 기능을 담당하는 클래스에 미리 정의해 놓을 순 있으나, UserDao 클래스나 ConnectionMaker의 코드 내에서는 드러나지 않는다. 이를 알 수 있는 시점은 어플리케이션이 실행된 이후인 런타임 시점이다. 이렇게 런타임 시점에 의존 관계를 맺는 대상을 의존 오브젝트라고 하다. 그리고 이렇게 구체적인 의존 오브젝트와 그것을 사용할 오브젝트를 런타임 시에 연결해주는 작업을 의존관계 주입이라고 정의한다.

 

의존관계 주입
 구체적인 의존 오브젝트와 그것을 사용할 오브젝트런타임 시에 연결해주는 작업을 말하며, 아래 세가지 조건을 충족한다.
 1) 클래스 코드에는 구체적인 의존 오브젝트에 대한 의존관계가 드러나지 않는다. 이를 위해서는 인터페이스에만 의존하고 있어야 한다.
 2) 런타임 시점의 의존관계는 컨테이너나 팩토리같은 제 3의 존재가 결졍한다.
 3) 의존관계는 사용할 오브젝트에 대한 래퍼런스를 외부에서 제공해줌으로써 만들어진다. 

 


6. 의존관계 주입의 응용

6.1. 기능 구현의 교환

 인터페이스 구현체로 기능을 구현하고 있으니, 기능 구현을 교환하려면 인터페이스에 대한 구현체 클래스를 새로 생성 후 DI 하는 부분에서 클래스만 교체하면 된다.

 

6.2. 부가 기능 추가

 DAO가 DB를 얼마나 많이 연결해서 사용하는 지를 파악하기 위해 DB 연결 횟수를 카운팅하는 기능을 추가한다고 가정한다면, DAO 로직마다 Count를 추가하는 게 아닌 DAO와 DB 커넥션을 만드는 오브젝트 사이에 연결 횟수를 카운팅하는 오브젝트를 추가하면 된다. 오브젝트 추가는 DI 설정으로 한다.

 

public class CountingConnectionMaker implements ConnectionMaker{

    int counter = 0;
    private ConnectionMaker realConnectionMaker;

    CountingConnectionMaker(ConnectionMaker realConnectionMaker){
        this.realConnectionMaker = realConnectionMaker;
    }
    
    @Override
    public Connection makeConnection() throws ClassNotFoundException, SQLException {
        this.counter++;
        return realConnectionMaker.makeConnection();
    }

    public int getCounter(){
        return this.counter;
    }
}

...

@Configuration
public class DaoFactory {
    @Bean
    public UserDao userDao(){
        return new UserDao(countingConnectionMaker());
    }

    @Bean
    public ConnectionMaker countingConnectionMaker(){
        return new CountingConnectionMaker(realConnectionMaker());
    }

    @Bean
    public ConnectionMaker realConnectionMaker(){
        return new DConnectionMaker();
    }
}


...

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

        // DAO 사용 코드

        CountingConnectionMaker ccm = applicationContext.getBean("countingConnectionMaker",
                CountingConnectionMaker.class);

        System.out.println(ccm.getCounter());
    }
}

 

 


7. 회고

 이번 주차에서는 빈의 스코프와 DI에 대해 알아보았다. 이번 기회로 DI에 대한 개념을 재정리 할 수 있게 되었다. DI가 단순히 어떠한 객체를 주입받는다라고 생각했었지만 코드 내에 의존 오브젝트가 드러나서는 안되고, 의존관계를 런타임 시점에 맺어야 한다는 것을 새롭게 알게되었다.

 또한 DI 활용 부분을 보며 AOP, 프록시와 같은 성질을 가진 기능들도 DI로 구현이 가능하겠구나 라는 생각이 들면서 이 기능이 스프링에서 제공하는 다양한 부분에 사용되고 있겠구나 라는 생각도 들었다.

 

반응형
반응형

1. 개요

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

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

 


2. 스프링이란?

 EJB 프레임워크에 반발하여 만든 POJO 방식의 프레임워크이다. EJB에 반발, POJO 방식이란 뭘까? 이는 자바 프로그래밍의 역사와 관련이 있다.

 EJB는 Enterprise Java Bean의 약자로 엔터프라이즈급 어플리케이션 개발을 위한 사용되던 프레임워크이다. 비즈니스 로직을 엔티티 빈이라는 곳에 넣어두고 JSP에서 이를 실행시키는 서버 컴포넌트이다.

 EJB의 큰 특징은 비지니스 로직 개발에 초점을 두어 객체지향적인 프로그래밍을 하지 않았다는 것인데, 이때문에 객체가 가진 기능이 많아지고, 책임이 많아짐에 따라 객체가 '무거워지게' 되었다. 이러한 EJB의 객체지향적이지 않은 개발 방식에 반발심을 가져 POJO 라는 말이 등장했다.

 POJO란 Plain Old Java Object 가볍고 오래된 방식의 자바 오브젝트라는 뜻이다. 자바 객체는 객체 지향적으로 설계해야 하며 가벼워야한다는 자바 프로그래밍의 기본 철학을 되새기기 위해 나온 단어이다.

 EJB 기술의 혼란속에서 잃어버렸던 객체 지향 기술의 가치를 회복하고 기본으로 돌아가기 위해 만들어진 프레임워크가 바로 스프링이다.

 

3. 초난감 DAO 예제

 초난감 DAO 예제를 두번 작성하였다. 처음 작성할 때에는 관심사를 구분하는 것에 초점을 맞췄고, 두번째 작성할 때에는 스프링의 IoC와 어떻게 연결되는지에 초점을 맞췄다.

 객체지향적인 설계를 위한 첫걸음은 관심사를 분리하는 것이다. 아래 초난감 DAO인 UserDao에 대해 관심사를 분리하는 방법을 Step By Step으로 알아보았다.

public class UserDao {

    public void add(User user) throws ClassNotFoundException, SQLException {

        Class.forName("com.mysql.jdbc.Driver");
        Connection c = DriverManager.getConnection("jdbc:mysql://localhost/spring","xxx","xxx");

        // 실행시킬 쿼리 생성
        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());

        // execute : 수행 결과를 Boolean 타입으로 반환
        // executeQuery : select 구문을 처리할 때 사용하며, 수행 결과를 ResultSet 타입으로 반환
        // executeUpdate : INSERT, UPDATE, DELETE 구문을 처리할 때 사용하며, 반영된 레코드 수를 int 타입으로 반환.
        ps.executeUpdate();

        // Close
        ps.close();
        c.close();
    }

    public User get(String id) throws ClassNotFoundException, SQLException{

        Class.forName("com.mysql.jdbc.Driver");
        Connection c = DriverManager.getConnection("jdbc:mysql://localhost/spring","xxx","xxx");

        // 쿼리 및 파라미터 셋팅
        PreparedStatement ps = c.prepareStatement(
                "select * from users where id = ?");
        ps.setString(1, id);

        // executeQuery 메서드를 사용하여 실행 결과를 ResultSet으로 반환
        ResultSet rs = ps.executeQuery();

        // ResultSet 값이 존재하는지 확인하며 존재할 경우 해당 레코드(다음 레코드)로 이동
        rs.next();

        User user = new User();
        user.setId(rs.getString("id"));
        user.setName(rs.getString("name"));
        user.setPassword(rs.getString("password"));

        rs.close();
        ps.close();
        c.close();

        return user;
    }

}

 이 클래스에는 현재 두가지 관심사가 있다. 첫째는 DB와 연결을 위한 Connection을 어떻게 가져올 것인가. 둘째는 사용자 등록 및 조회를 위해 DB에 보낼 SQL을 어떻게 작성할 것인가. 셋째는 작업이 끝나면 사용한 리소스는 어떻게 반환할 것인가. 이번 예제에서는 첫째 관심사인 DB와 연결을 분리하는 것을 목표로 하였다. 

 

3.1. 중복 코드에 대한 메서드 추출

 get, add 메서드에 Connection을 가져오는 로직이 중복되어 있다. 중복된 로직은 메서드로 추출하였다. 이로써 얻을 수 있는 이점은 Connection 정보가 변경될 경우 생성한 메서드의 코드만 변경하면 된다.

public class UserDao {

    public void add(User user) throws ClassNotFoundException, SQLException {

        Connection c = getConnection();

        ...
    }

    public User get(String id) throws ClassNotFoundException, SQLException{


        Connection c = getConnection();

        ...

        return user;
    }
    
    public Connection getConnection() throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.jdbc.Driver");
        return DriverManager.getConnection("jdbc:mysql://localhost/spring","xxx","xxx");
    }

}

 

3.2. 상속을 통한 분리

 메서드 추출만으로 변화에 유연하게 대처할 수 있으나, 변화를 반길 순 없다. 변화를 반기는 DAO, 즉 어떤 변화에도 끄떡없는 DAO를 만들어보자.

 교재에서 가정한 변화의 예시는 다음과 같았다.

 

필자가 구현한 UserDao가 업계에 알려지면서 N사와 D사에서 사용자 관리를 위해 UserDao를 구매하겠다는 주문이 들어왔다. 그런데 납품 과정에서 문제가 발생했다. N사와 D사가 각기 다른 종류의 DB를 사용하고 있고, DB 커넥션을 가져오는 데 있어 독자적으로 만든 방법을 적용싶어한다는 점이다. 더 큰 문제는 UserDao를 구매한 후에도 DB 커넥션을 가져오는 방법이 변경될 가능성이 있다는 점이다. 
 이 경우 UserDao의 코드를 고객에게 제공해주고, 변경이 필요하면 getConnection() 메서드를 수정해서 사용하라고 할 수 있으나, 고객에게 소스를 직접 공개하고 싶지 않아 컴파일된 클래스 파일만 제공하려 한다. UserDao 소스코드를 N사와 D 사에게 제공해주지 않고도 고객 스스로 원하는 DB 커넥션 생성 방식을 적용해가면서 UserDao를 사용하게 할 수 있을까?
 

이 경우 UserDao 코드를 한단계 더 분리한다. UserDao를 추상 클래스로 만들고 getConnection() 을 추상메서드로 만드는 방법이다. 그럼 각자 UserDao를 상속받아 getConnection 메서드만 각자 회사의 독자적인 기술로 만들기만 하면 된다. 이를 상속을 통한 분리라고 한다.

public abstract class UserDao {

    public void add(User user) throws ClassNotFoundException, SQLException {

        Connection c = getConnection();

        ...
    }

    public User get(String id) throws ClassNotFoundException, SQLException{


        Connection c = getConnection();

        ...

        return user;
    }
    public abstract Connection getConnection() throws ClassNotFoundException, SQLException;
}

public class DUserDao extends UserDao{

    @Override
    public Connection getConnection() throws ClassNotFoundException, SQLException {
        // D사 Connection 생성 코드
    }
}


public class NUserDao extends UserDao{

    @Override
    public Connection getConnection() throws ClassNotFoundException, SQLException {
        // N사 Connection 생성 코드
    }
}

 

 N사, D사에서는 각각 NUserDao, DUserDao 클래스를 만들고 UserDao를 상속받아 getConnection을 정의하면 된다. 이로써 DAO의 핵심 기능인 '어떻게 데이터를 등록하고 가져올 것인가'라는 관심을 담당하는 UserDao와, 'DB 연결 방법은 어떻게 할 것인가' 라는 관심을 담고 있는 NUserDao, DUserDao가 클래스 레벨로 분리되었다. 클래스 단위로 분리되면서 변경 작업은 한층 용이해졌다. 이제는 UserDao의 코드는 한 줄도 수정할 필요 없이 DB 연결 기능을 클래스를 만들어 사용할 수 있다.

 또한 이번 리팩토링으로 인해 UserDao와 구현 클래스는 템플릿 메서드 패턴과 팩토리 메서드 패턴이 적용되었다고 말할 수 있다.

 

템플릿 메서드 패턴
 이처럼 슈퍼클래스에 기본적인 로직의 흐름(get, add)을 만들고, 그 기능의 일부(getConnection)를 추상 메소드나 오버라이딩이 가능한 메소드로 만든 뒤 서브 클래스에서 구현하도록 하는 방법을 디자인 패턴에서 템플릿 메서드 패턴이라고 한다.
팩토리 메서드 패턴
 UserDao의 서브클래스의 getConnection() 메서드는 Connection 오브젝트를 어떻게 생성할 것인지를 결정하는 방법이라고도 볼 수 있다. 이렇게 서브클래스에서 구체적인 오브젝트 생성 방법을 결정하게 하는 것을 팩토리 메서드 패턴이라고 한다.

 

 하지만 이 방식은 상속을 사용했다는 단점이 있다. 상속은 한계가 있다. 바로 다중 상속을 허용하지 않는다는 점이다. 만약 NUserDao와 DUserDao가 다른 목적을 위해 다른 클래스를 상속하고 있다면 UserDao를 상속받지 못하게 된다.

 또 다른 문제는 상속을 통한 상하위 클래스의 관계는 밀접하다는 점이다. 서브 클래스는 슈퍼 클래스의 기능을 직접 사용할 수 있다. 그래서 슈퍼 클래스 내부의 변경이 있을 때 서브클래스를 함께 수정하거나 다시 개발해야 할 수도 있다.

 확장된 기능인 DB 커넥션을 생성하는 코드를 다른 DAO 클래스에 적용할 수 없다는 것도 단점이다. Dao 클래스들이 계속 만들어진다면 상속을 통해 만들어진 getConnection() 의 구현 코드가 매 DAO 클래스마다 중복될 것이다.

 

3.3. 클래스의 분리

 DB 커넥션과 관련된 부분을 서브 클래스가 아니라, 아예 별도의 클래스로 만들고 이렇게 만든 클래스를 UserDao에서 생성하여 사용하게 만든다. 이럴 경우 상속을 통한 한계점을 극복할 수 있고, 다른 DAO 클래스에도 해당 클래스를 사용하게 만든다면 구현코드의 중복도 막을 수 있다.

 

public class UserDao {

    private ConnectionFactory connectionFactory;

    public UserDao(){
        connectionFactory = new ConnectionFactory();
    }
    public void add(User user) throws ClassNotFoundException, SQLException {

        Connection c = connectionFactory.makeNewConnection();

        ...
    }

    public User get(String id) throws ClassNotFoundException, SQLException{

        Connection c = connectionFactory.makeNewConnection();
        
		...
    }
}

public class ConnectionFactory {

    public Connection makeNewConnection() throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.jdbc.Driver");
        return DriverManager.getConnection("jdbc:mysql://localhost/spring","xxx","xxx");
    }

}

 

 ConnectionFactory 클래스를 만들고 UserDao에서는 ConnectionFactory 필드를 추가한다. N사에서 ConnectionFactory 방식으로 Connection을 가져오고 있다고 가정한다. 이 후 기본 생성자 주입을 통해 ConnectionFactory를 받아 add(), get() 메서드에서 사용하도록 한다.

 성격이 다른 코드를 클래스로 분리하였으나 N사와 D사에서 상속을 통해 DB 커넥션 기능을 사용했던 게 불가능해졌다. 현재 UserDao가 N사의 커넥션 생성 방식인 ConnectionFactory 클래스에 종속되어 있기 때문에  D 사에서 다른 커넥션 생성 방식, 예를들면 SimpleConnectionFactory 클래스를 만들어 사용한다고 할 경우 UserDao를 그에 맞게 또 변경해줘야하기 때문이다. 결국 처음으로 돌아와버린 격이다.

 

 이렇게 클래스를 분리한 경우에도 자유로운 확장이 가능하게 하려면 구체적인 방법에 종속되지 않도록 해야한다. 즉, ConnectionFactory, SimpleConnectionFactory와 같은 클래스와 UserDao를 이어주는 추상적인 연결고리를 만들어주는 것이다.

 

추상화
 추상화란 어떤 것들의 공통적인 성격을 뽑아내서 분리해내는 작업을 말한다. 자바가 추상화를 위해 제공하는 가장 유용한 도구는 인터페이스이다.

 

 즉, UserDao에서 사용하는 커넥션 생성 방식에 대한 객체를 만드려면 구체적인 클래스 하나를 선택해야겠지만, 인터페이스를 사용할 경우 구체적인 클래스가 정확히 무엇인지 몰라도 된다. 인터페이스를 도입해야한다.

 

3.4. 인터페이스 도입

 ConnectionMaker라는 인터페이스를 정의하고 DB Connection을 가져오는 메소드 이름을 makeConnection이라고 정한다. 이 인터페이스를 사용하는 UserDao는 ConnectionMaker 인터페이스 타입의 오브젝트라면 어떤 클래스로 만들어졌는지 상관없이 makeConnection() 메서드를 호출하기만 하면 Connection 타입의 오브젝트를 만들어서 돌려줄 것이라 기대할 수 있다.

public interface ConnectionMaker {

    public Connection makeConnection() throws ClassNotFoundException, SQLException;
}

public class DConnectionMaker implements ConnectionMaker{

    @Override
    public Connection makeConnection() throws ClassNotFoundException, SQLException {
        // D사의 Connection 생성 코드
    }
}

public class NConnectionMaker implements ConnectionMaker {

    @Override
    public Connection makeConnection() throws ClassNotFoundException, SQLException {
        // N사의 Connection 생성 코드
    }
}

public class UserDao {

    private ConnectionMaker connectionMaker;

    public UserDao(){
        this.connectionMaker = new DConnectionMaker();
    }
    public void add(User user) throws ClassNotFoundException, SQLException {

        Connection c = connectionMaker.makeConnection();

        ...
    }

    public User get(String id) throws ClassNotFoundException, SQLException{

        Connection c = connectionMaker.makeConnection();

        ...
    }
}

UserDao에서는 ConnectionMaker의 인터페이스와 인터페이스 메서드인 makeConnection()을 사용하도록 하고 N사와 D사에서는 ConnectionMaker의 인터페이스 구현체를 각자 생성하도록 하였다. 하지만 문제가 하나 있다. UserDao의 기본 생성자에 DConnectionMaker라는 클래스를 생성하여 connectionMaker에 주입하고 있다. 이렇게 된 이유는 어떤 ConnectionMaker 구현 클래스를 사용할지를 UserDao에서 결정하고 있기 때문이다. UserDao가 사용할 ConnectionMaker의 구현 클래스를 설정해주는 관심이 UserDao에 남아있다. 이러한 설정을 관계 설정이라고 한다. 이 관계설정이라는 관심을 분리해야한다.

 

3.5. 관계설정 분리

 관계를 설정하는 클래스를 만들고 그 클래스의 관심사를 ConnectionMaker 구현체를 결정하는 것으로, 즉 관계 설정 책임을 담당하는 클래스로 사용한다. 필자는 Main 이라는 클래스를 생성하고 main 메서드에서 관계를 설정해주었다.

 N사에 대한 ConnectionMaker 방식을 사용하고자 할 경우 UserDao 생성자 파라미터로 NConnectionMaker 객체를, D사에 대한 ConnectionMaker 방식을 사용하고자 할 경우 DConnectionMaker 객체를 넘겨주면 된다. 그럼 UserDao의 코드 변경 없이 확장까지 가능한 UserDao 클래스가 구현되었다.

public class UserDao {

    private ConnectionMaker connectionMaker;

    public UserDao(ConnectionMaker connectionMaker){
        this.connectionMaker = connectionMaker;
    }
    public void add(User user) throws ClassNotFoundException, SQLException {

        Connection c = connectionMaker.makeConnection();

        ...
    }

    public User get(String id) throws ClassNotFoundException, SQLException{

        Connection c = connectionMaker.makeConnection();

        ...
    }
}

public class Main {
    public static void main(String[] args) throws SQLException, ClassNotFoundException {
        ConnectionMaker connectionMaker = new DConnectionMaker();
        UserDao userDao = new UserDao(connectionMaker);
        User user = new User();
        user.setId("test");
        user.setName("테스트");
        user.setPassword("1234");

        userDao.add(user);

        ...
    }
}

 


4. 초난감 DAO 예제의 목적

 그럼 초난감 DAO 의 목적은 무엇이었을까. 필자가 생각했을 땐 객체지향 설계의 기본 원칙과 패턴들이 이런것이다 라는 것을 상기시켜주기 위함이 아닐까 생각한다.

 관심사를 분리시키다보니 객체 지향적으로 코드가 리팩토링됐다. 이 과정에서 템플릿 메서드 패턴, 팩토리 메서드 패턴, 전략 패턴이 적용되었고, 객체지향 설계 원칙 중 하나인 개방폐쇄 원칙이 적용되었다. 이렇게 리팩토리된 코드는 높은 응집도낮은 결합도를 가졌다고 표현한다.

 

개방 폐쇄 원칙
개방 폐쇄 원칙(OCP)은 객체지향 5대 원칙(SOLID) 중 하나로 '클래스나 모듈은 확장에는 열려 있어야 하고 변경에는 닫혀 있어야 한다'라는 원칙이다.

 

 UserDao는 DB 연결 방법 기능을 확장하는 데는 열려있다. 그에반에 UserDao의 핵심 기능, 즉 주요 관심사인 등록과 조회는 DB 연결 방법의 변화에는 영향을 받지 않기때문에 변경에는 닫혀있다라고 할 수 있다. 다시말하면 인터페이스를 통한 확장 포인트는 확장을 위해 열려있고, 인터페이스를 사용하여 주요 관심사를 수행하는 클래스는 자신의 변화가 불필요하게 일어나지 않도록 굳게 닫혀되어 있다.

 

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

 

 개선한 Main - UserDao - ConnectionMaker 구조를 디자인 패턴의 시각으로 보면 전략 패턴이라고 한다. 전략 패턴의 정의를 우리가 구현한 클래스에 대입하면 다음과 같다.

 UserDao 클래스에서 변경이 필요한 DB Connection을 가져오는 로직을 ConnectionMaker라는 인터페이스를 사용해 외부로 분리시키고, 이를 구현한 구체적인 로직인 DConnectionMaker, NConnectionMaker를 필요에 따라 Main 클래스에서 바꿔 사용할 수 있게 하는 것이다.

 

높은 응집도
 개방 폐쇄 원칙은 높은 응집도와 낮은 결합도를 가진다. 응집도가 높다는 건 하나의 클래스가 하나의 관심사에 집중되어 있다는 뜻한다. 관심사에 대한 변경이 일어날 때 해당 클래스에서 많은 변경이 일어난다면 응집도가 높고, 다른 클래스에서도 변경이 일어난다면 응집도가 낮다는 것이다.

 

 UserDao 클래스는 사용자의 데이터를 처리하는 관심사에 집중되어 있고, ConnectionMaker의 구현체는 자체적인 Connection을 생성하는 관심사에 집중되어 있다. 이렇듯 하나의 관심사가 하나의 클래스에만 응집되어 있으므로 응집도가 높다라고 할 수 있다.

 

낮은 결합도
 결합도가 낮다는 건 관심사가 다른 클래스와 느슨하게 연결된 것을 뜻한다. 여기서 결합도란 하나의 오브젝트가 변경이 일어날 때에 관계를 맺고 있는 다른 오브젝트에게 변화를 요구하는 정도를 말한다.

 

 ConnectionMaker 인터페이스의 도입으로 인해 DB 연결 기능을 구한한 클래스가 바뀌더라도 UserDao의 코드는 변경될 필요가 없다. ConnectionMaker와 UserDao의 결합도가 낮다는 뜻이다. 

 


5. IoC

 IoC는 Inversion Of Controller의 약자로 제어의 역전을 뜻한다. 갑자기 이 용어가 튀어나온 이유는 작성한 예제에도 IoC가 적용되어 있기 때문이다.

 UserDao라는 클래스에서 사용하는 ConnectionMaker의 구현체는 UserDao 본인이 결정한게 아니다. Main 클래스에서 ConnectionMaker의 구현 객체를 제어하고 있다. 이를 제어의 역전이라고 한다. 제어의 역전이 적용되면 특정 오브젝트는 자신이 사용할 오브젝트를 스스로 선택하지 않게 된다. 생성하지도 않으며 자신도 어떻게 만들어지는지 알 수 없다.


6. 오프젝트 팩토리

  자신이 사용할 오브젝트를 다른 클래스에서 제어한다는 IoC의 개념을 되새기며 UserDao 코드를 리팩토링 해보자.

 현재 Main 클래스는 UserDao의 기능이 잘 동작하는지에 대한 관심 UserDao 클래스에 구현 객체를 제어하는 IoC 기능에 대한 관심까지 갖고 있다. 관심사가 하나가 아니므로 응집도가 낮고 결합도가 높은 상황이다. IoC 기능에 대한 관심사 분리를 해보자.

 분리시킬 기능을 담당하는 클래스를 만들어야하는데, 이 클래스의 역할은 객체의 생성 방법을 결정하고 그렇게 만들어진 오브젝트를 돌려주는 것(IoC의 기능)이다. 이런 일을 하는 오브젝트를 팩토리 또는 오브젝트 팩토리라고 부른다.  

 

 팩토리 역할을 맡을 클래스를 DaoFactory라고 하고 UserDao, ConnectionMaker에 대한 생성 작업을 DaoFactory로 옮긴다.

public class DaoFactory {
    public UserDao userDao(){
        ConnectionMaker connectionMaker = new DConnectionMaker();
        return new UserDao(connectionMaker);
    }
}

public class Main {
    public static void main(String[] args) throws SQLException, ClassNotFoundException {
        UserDao userDao = new DaoFactory().userDao();
        User user = new User();
        user.setId("test");
        user.setName("테스터");
        user.setPassword("1234");

        userDao.add(user);

        ...
    }
}

 

 DaoFactory의 userDao 메서드를 호출하면 DConnectionMaker를 사용해 DB 커넥션을 가져오도록 한 UserDao 오브젝트를 반환한다. Main 클래스는 UserDao가 어떻게 만들어지고 초기화되는지에 대한 관심사에서 분리되었다.

 만약 DaoFactory에 UserDao가 아닌 다른 DAO의 생성 기능을 넣으면 어떻게 될까? 그럼 아래와 같이 ConnectionMaker의 구현체를 생성하여 관계를 맺어주는 코드가 중복될 것이다. DAO가 많을 경우 ConnectionMaker의 구현체를 변경해야 한다면 모든 메서드를 수정해야하는 문제가 생긴다.

public class DaoFactory {
    public UserDao userDao(){
        ConnectionMaker connectionMaker = new DConnectionMaker(); // 중복
        return new UserDao(connectionMaker);
    }

    public AccountDao accountDao(){
        ConnectionMaker connectionMaker = new DConnectionMaker(); // 중복
        return new AccountDao(connectionMaker);
    }

    public MessageDao messageDao(){
        ConnectionMaker connectionMaker = new DConnectionMaker(); // 중복
        return new MessageDao(connectionMaker);
    }
}

 

 중복 문제 해결을 위해 메서드를 분리하는 방법을 사용하면 아래와 같이 DAO 팩토리 메서드가 많아져도 중복코드로 인한 문제가 발생하지 않는다.

public class DaoFactory {
    public UserDao userDao(){
        return new UserDao(connectionMaker());
    }

    public AccountDao accountDao(){
        return new AccountDao(connectionMaker());
    }

    public MessageDao messageDao(){
        return new MessageDao(connectionMaker());
    }
    
    public ConnectionMaker connectionMaker(){
        return new DConnectionMaker();
    }
}

 

 여기서 IoC 개념을 다시 한번 생각해보자. UserDao 클래스는 자기가 사용할 ConnectionMaker의 구현체를 자기가 결정하는 게 아니라 DaoFactory 클래스가 결정해주고 있다. UserDao 자신도 팩토리에 의해 수동적으로 만들어지고 자신이 사용할 오브젝트도 팩토리가 주는대로 사용하는 입장이 되었다. 바로 이게 제어의 역전 즉, IoC이다.

 IoC를 이토록 강조한 이유는 IoC를 기본 배경으로 사용하는 대표적인 프레임워크가 바로 스프링이기 때문이다. 이제 이 스프링에서 사용하는 IoC를 적용해볼 차례이다.


7. 스프링 IoC

 스프링 IoC 컨테이너에서 관리되는 자바 객체를 (bean)이라고 하며, 빈의 생성과 관계설정 같은 제어를 담당하는 IoC 오브젝트를 빈 팩토리(bean Factory)라고 한다. 보통 빈 팩토리보다는 이를 좀 더 확장한 용어인 애플리케이션 컨텍스트를 주로 사용한다. 빈 팩토리라는 용어는 빈을 생성하고 관계를 설정하는 IoC의 기본 기능에 초점을 맞춘 것이고, 애플리케이션 컨텍스트는 애플리케이션 전반에 걸치는 모든 구성요소의 제어 작업을 담당하는 IoC 엔진이라는 의미로 사용한다.

 DaoFactory를 스프링의 빈 팩토리가 사용할 수 있는 설정정보로 만들어야 한다. @Configuration 어노테이션을 DaoFactory에 추가하고, 오브젝트를 만들어주는 메서드에 @Bean 어노테이션을 추가한다.

@Configuration
public class DaoFactory {
    @Bean
    public UserDao userDao(){
        return new UserDao(connectionMaker());
    }

    @Bean
    public ConnectionMaker connectionMaker(){
        return new DConnectionMaker();
    }
}

 

이제 DaoFactory를 설정정보로 사용하는 애플리케이션 컨텍스트를 만들어야한다. 어플리케이션 컨텍스트는 ApplicationContext 타입의 오브젝트이며 @Configuration 어노테이션이 붙은 클래스를 설정정보로 사용하려면 AnnotationConfigApplicationContext 생성자를 사용하고 생성자 파라미터에 넣어주면 된다.

public class Main {
    public static void main(String[] args) throws SQLException, ClassNotFoundException {
        
        ApplicationContext applicationContext = 
        	new AnnotationConfigApplicationContext(DaoFactory.class);
        UserDao userDao = applicationContext.getBean("userDao", UserDao.class);
        User user = new User();
        user.setId("test");
        user.setName("테스터");
        user.setPassword("1234");

        userDao.add(user);

        ...
    }
}

 

getBean 메서드는 ApplicationContext가 관리하는 빈을 요청하는 메서드이다. userDao는 ApplicationContext에 등록된 빈의 이름이며, DaoFactory에서 @Bean 어노테이션이 붙은 메서드이다. 메서드 이름이 바로 빈의 이름이 되는것이다.

 애플리케이션의 동작 방식은 다음과 같다.

토비의 스프링 Application Context 구조 참고

 1) 애플리케이션 컨텍스트 객체 생성 시 DaoFactory 클래스를 설정정보로 등록해두고, @Bean이 붙은 메소드의 이름을 가져와 빈 목록을 만들어둔다.

 2) 클라이언트가 애플리케이션 컨텍스트의 getBean() 메서드를 호출하면 자신의 빈 목록에서 요청한 이름이 있는지 찾는다.

3) 있다면 빈을 생성하는 메서드를 호출해서 오브젝트를 생성시킨 후 클라이언트에게 반환한다. 

 

 * 이 시점에 생성된 오브젝트는 스프링 컨테이너에 생성된 오브젝트 즉 빈(bean)이며, 스프링 컨테이너 빈의 스코프는 기본적으로 싱글톤이다. 때문에 똑같이 getBean() 메서드를 한번 더 호출할 경우 오브젝트를 생성하지 않고 이미 등록된 빈을 리턴한다.

 

참고로 추가한 스프링 라이브러리는 다음과 같다. (gradle)

    implementation 'cglib:cglib:3.3.0'
    implementation 'org.apache.commons:com.springsource.org.apache.commons.logging:1.1.1'
    implementation 'org.springframework:spring-asm:3.1.4.RELEASE'
    implementation 'org.springframework:spring-beans:3.1.4.RELEASE'
    implementation 'org.springframework:spring-context:3.1.4.RELEASE'
    implementation 'org.springframework:spring-core:3.1.4.RELEASE'
    implementation 'org.springframework:spring-expression:3.1.4.RELEASE'

 


8. 회고

 스프링을 어느정도 써봤고, 스프링에 대한 용어를 어느정도 알고 있는 상태에서 이 책을 한번 더 읽으니 첫번째 읽었을 때보다 이해되고 공감되는 부분이 많았다. 복잡하고, 불필요하게만 느껴졌던 내용들도 글쓴이가 무슨 의도로 이러한 말을 했는지 이해하게 되었다.

 분량이 50페이지 정도이고 처음 부분이라 몇 시간정도면 끝낼 줄 알았던 게 이틀이나 걸려 당황스러웠다(그만큼 배운게 많았다 ㅎㅎ). 2주차 스터디는 여유있게 시간을 더 투자하여 많은 내용들을 머릿속에 재정립할 수 있도록 해야겠다.

 

반응형
반응형

1. 과제

 어플리케이션을 문서화하고 Docker를 사용하여 빌드하라.

 

2. 배운점

2.1. 도커 개념

 도커, 도커 파일, 도커 이미지, 컨테이너와 같은 개념을 정리하였다.

 

2.2. 문서화

 JavaDoc를 사용해 주석들을 문서화하고,  asciidoctor를 사용해 API도 문서화하는 방법을 알아보았다.

 

3. 느낀점

 사실 asciidoctor를 통해 API 문서화 방법을 실습하면서 'Swagger를 통해 API 문서를 생성하는 방법을 알려주는게 더 좋지 않았을까' 라는 생각이 많이 들었다. javaDoc과 같은 경우는 API 명세 뿐 아니라 비지니스 로직에 대한 명세도 상세히 기입할 수 있어 이점이 있으나, asciidoctor과 같은 경우는 개발자가 작성한 테스트 코드를 통해 API 명세를 제공하여 누락될 여지가 있고, 실제 API 테스트도 Swagger가 훨씬 간편하기 때문이다.

 도커의 개념, 도커 파일, 이미지 생성, 컨테이너 빌드에 대한 내용이 한 시간 남짓한 강의로 제공되었는데, 강의의 코드를 보고 따라치는 느낌이라 도커에 대한 자세한 내용은 얻어갈 순 없었다. 도커에 대한 일반적인 프로세스 및 개념정도의 이해를 목적으로 한 것 같았다.

 이로써 마지막 8주차 강의까지 모두 끝이났다. 8주간 받았던 피드백들 하나하나 정말 소중했고, 특히 테스트 주도 개발을 위해 어떻게 시작해야 할지에 대한 방향이 잡힌 것 같다. 8주의 기간동안 받았던 피드백들을 다시한번 복기하며 정리하여 추후 있을 토이 프로젝트 배운 내용을 적용할 수 있도록 할 것이다.

 

반응형
반응형

1. 과제

 Spring Security를 사용하여 비밀번호 암호화 및 인증, 인가를 적용하라.

 

2. 배운점

2.1. JWT RefreshToken과 AccessToken

 인증 시 JWT RefreshToken와 AccessToken을 발급하는 방식을 적용해보았다. RefreshToken은 DB에 저장하였고, AccessToken 만료 시 RefreshToken을 통해 AccessToken을 재발급해주는 기능을 구현하였다.  현재는 AccessToken 만료 시 클라이언트에게 401에러와 Expired 에러를 알려주는데, 이렇게 할 경우 클라가 RefreshToken을 통해 AccessToken을 재발급받는 API를 타야하는 번거로움이 있다. RefreshToken을 세션에 넣어두고 AccessToken이 만료됐을 때 세션에서 RefreshToken을 가져와 AccessToken을 재발급해주는 Filter를 생성한다면 이러한 번거로움을 없앨 수 있을 것 같다.

 

2.2. 어노테이션 기반 인가처리

 이전에는 SecurityConfig 설정 파일 antMatcher.hasRole과 같은 메서드를 사용하여 인가처리를 하였는데,  Controller 메서드에 @PreAuthorize 를 사용하여 인가처리를 하는 방법을 알게되었다. 설정파일에 할 경우 antMatcher.hasRole 메서드의 순서에 따라 본인이 원하는 인가 처리가 되지 않을 수 있는데 반에 이 방식은 보다 명시적인 느낌을 받았다.

 

2.3. JWT + SpringSecurity

  SpringSecurity에  JWT 인증 방식을 적용해보았다. AccessToken와 RefreshToken을 사용한 JWT 인증의 프로세스를 이해하니 폼 인증 방식보다 훨씬 유연하다는 생각이 들었다.

 

3. 느낀점

 RefreshToken, AccessToken을 사용한 JWT 인증, 인가 프로세스, SpringSecurity 설정 파일에 입력했던 CSRF, Session 관련 메서드들의 의미와 사용 이유를 확실히 이해하게 되었다.

 TDD를 위해 테스트 코드를 선 작성하고 로직을 구현하는 것도 훨 자연스러워졌다. 이 기능은 어떤 클래스가 책임져야 할지, 전역객체에 너무 의존하는 로직은 아닌지, 테스트가 어렵진 않을지 생각하며 코드를 작성하는 습관을 갖는게 중요하다는 것도 확실히 느끼게 된 주 차 였다.

 

반응형
반응형

1. 과제

 JWT 토큰을 사용하여 인증, 인가를 구현하라

 

2. 배운점

2.1. JWT

 JWT 토큰을 적용하여 인증 처리를 하였다. 토큰 적용 후 사용목적이나 한계점, 효과적인 토큰 사용 방법들에 대해 공부하며 JWT 토큰에 대한 개념을 확립할 수 있었다. 정리한 개념은 따로 포스팅하였다.

https://tlatmsrud.tistory.com/87

 

2.2. Interceptor에 대한 테스트 코드

 요청에 대한 권한 여부를 체크하는 방식으로 커스텀 어노테이션 방식을 채택했다.

클라이언트로부터 요청이 들어왔을 때 실행되는 컨트롤러 메서드에 커스텀 어노테이션(@CheckJwtToken)이 있을 경우 JWT 토큰을 검증하도록 하였으며 이를 위해 앞단에 Interceptor를 구현하였다.

 Interceptor의 테스트 코드 작성 시 preHandler메서드에 대한 테스트 작성이 어려웠다. HttpServletRequest, HttpServletResponse, Handler에 대한 파라미터를 테스트코드에서 어떻게 넘겨야할지 감이 안잡혔기 때문이다. 우여곡절 끝에 MockHttpServletRequest, MockHttpServletResponse, HandlerMethod로 구현하게 되었다.(멘토님 감사합니다.)

HandlerMethod로 구현한 이유
 디스패처 서블릿은 애플리케이션이 실행될 때 모든 컨트롤러의 메소드를 추출한 뒤 HandlerMethod 형태로 저장해두고, 실제 요청이 들어오면 요청 조건에 맞는 HandlerMethod를 참조하여 해당 메서드를 실행시킨다.
 실제 API에 대한 요청이 들어올 경우 Interceptor의 preHandler 메서드 파라미터인 handler에 HandlerMethod가 들어오기 때문에 HandlerMethod로 구현하였다.

 

HandlerMethod 생성자 파라미터는 beanObject, methodName, parameter로 구성되는데, 테스트 클래스에 MockHandler 클래스를 하나 만들어 아래과 같이 구현하고, beanObject는 테스트 클래스를, methodName에는 @CheckJwtToken가 포함된 메서드 명을 기재하였다. 이로써 Interceptor에 대한 테스트코드를 작성할 수 있었다. 아래는 MockHandler 클래스와 preHandler 메서드에 대한 테스트 코드이다.

class MockHandler{
	@CheckJwtToken
    public void handleRequest(){

    }
}

 

@Test
	void withInvalidTokenReturnFalse() throws Exception {
		MockHttpServletRequest request = new MockHttpServletRequest();
 		MockHttpServletResponse response = new MockHttpServletResponse();

		HandlerMethod handler = new HandlerMethod(new MockHandler(), "handleRequest");

		request.setMethod("GET");
		request.addHeader("Authorization", "Bearer "+INVALID_TOKEN);

		// 유효하지 않은 토큰일 경우 PreHandle 메서드에서 예외가 발생하며 최종적으로 false를 리턴함.
		boolean result = loginCheckInterceptor.preHandle(request, response, handler);

		assertThat(result).isFalse();
 }

 

2.3. ParameterizedTest

 테스트 메서드의 로직은 동일하나 특정 변수 값만 다른 테스트 케이스가 있다. 예를들어 유효하지 않은 토큰 검증에 대한 테스트 시 토큰의 요청 값으로 null, 빈 값, 쓰레기값이 들어갈 수 있는데, 이를 각각 테스트하기 위해 다음과 같이 3개 메서드로 구현할 수 있다.

void parseTokenWithBlankToken(){
	assertThatThrownBy(() -> authenticationService.parseToken(" "))
  		.isInstanceOf(InvalidTokenException.class);
}

void parseTokenWithNullToken(){
	assertThatThrownBy(() -> authenticationService.parseToken(null))
  		.isInstanceOf(InvalidTokenException.class);
}

void parseTokenWithInvalidToken(){
    // INVALID_TOKEN 은 실제 유효하지 않은 토큰을 담은 static 변수임.
	assertThatThrownBy(() -> authenticationService.parseToken(INVALID_TOKEN))
  		.isInstanceOf(InvalidTokenException.class);
}

위의 경우 테스트 로직은 동일하나 토큰 값이 달라서 각각의 메서드를 만들어 처리하였는데, 이처럼 특정 변수 값만 다르고 로직이 동일할 경우 ParameterizedTest를 통해 하나의 메서드로 테스트 가능하다.

@ParameterizedTest
    @ValueSource(strings = { INVALID_TOKEN, "", null})
    void parseTokenWithInvalidTokens(String token){
        assertThatThrownBy(() -> authenticationService.parseToken(token))
                .isInstanceOf(InvalidTokenException.class);
    }

 

3. 느낀점

 실무에서 JWT 토큰 적용을 검토했던 적이 있다. 적용을 목적으로 했던거라 개념적인 내용을 많이 건너뛰었던 것 같았는데 이번 기회에 JWT 토큰에 대한 기본 개념, 사용 이유, 보안상 이점, 한계점과 같은 것들을 공부하게 되어 좋았다. 

 테스트 코드는 Controller, Service, Repository 정도로만 구현을 하다가 이번에 처음으로 Interceptor를 구현하게 되었는데 처음 해보는거라 감이 아예 잡히지 않았었다. prehandle 메서드 검증을 위해 파라미터를 채우는 방법을 몰랐기 때문이었는데, 멘토님께서 도와주시어 해결할 수 있었다.

 이제 2주 남았다. 남은 2주도 미루지 않고 잘 해보자!

반응형
반응형

1. 과제

 TDD 기반의 회원 관리 REST API를 구현하라.

 

2. 배운점

2.1. Validation

 하나의 DTO에 대해 상황에 맞는 Validation을 체크해야하는 부분이 있었다. 예를들어 유저 최초 생성시에는 모든 필드에 대한 NotBlank를 적용하고, 수정시에는 몇몇 필드에 대한 NotBlank를 적용하는 것과 같은 부분이다.

 사내 프로젝트에 적용할때는 DTO Class를 inner Class로 관리하여 Create, Update Class를 생성하고 각각에 대해 Validation을 적용했었는데 찾아보니 Validation Group을 지정하는 방법이 있어 이를 적용해보았다.

 적용해본 결과 그 방법이 간단하지 않고, 결과적으로 하나의 DTO에 여러 기능들이 응집되어 있는 형태가 되었다. 만약 Validation이 더 추가된다면 거대한 DTO가 될 우려가 있다는 멘토님의 조언을 받을 수 있었다. Validation은 InnerClass가 좋은 방법인 것 같다.

 

2.2. 테스트 커버리지의 목적

 과제가 끝나면 테스트 커버리지를 체크한다. 커버리지를 100%로 맞추려하다보니 비지니스 로직이 없는 DTO도 테스트 클래스를 별도로 만들게 되었는데 이렇게 하다보면 결국 '기존 코드와 테스트코드를 1:1비율로 만들게 되겠는데?' 라는 우려가 들기 시작했다. 테스트 커버리지가 100%가 아니라는 건 뭔가 헛점이 있는 어플리케이션이라는 생각이 들었기 때문이다. 이에 대해 멘토님께서 좋은 참고자료를 주셨는데, 글을 읽어보니 필자처럼 100% 커버리지에 너무 신경쓰는 사람들에 대한 따끔한 조언의 포럼이었다.

 테스트 커버리지의 목적은 '테스트 되지 않는 부분이 있는지 확인'하기 위함이다. DTO의 경우 테스트가 필요 없는 부분이므로 굳이 이에대한 테스트코드를 작성할 필요가 없었다.

 

2.3. Fixture 관리

 테스트 코드 작성 중 중복되는 고정 데이터를 Fixture라고 한다. 예를들어 Update를 위한 객체가 이에 해당하는데 다음과 같이 Builder나 new 생성자를 통해 생성하게 된다.

UserData userData = UserData.builder()
                .name("앙김홍집")
                .email("hongzip@naver.com")
                .password("123123")
                .build();

 BDD 테스트의 경우 사용자의 여러 행동을 예측하여 테스트 코드를 작성하게 되는데 이럴 경우 하드코딩이 들어간 이 로직이 중복되게 되는데 이 경우 Enum을 통해 깔끔하게 관리할 수 있다.

 

public enum UserFixture {

    UPDATE_USER(1L, "앙김홍집","hongzip@naver.com","123123"),
    CREATE_USER(2L, "앙생성집","sangzip@naver.com","12345");

    private final Long id;
    private final String name;
    private final String email;
    private final String password;

    private UserFixture(Long id, String name, String email, String password){
        this.id = id;
        this.name = name;
        this.email = email;
        this.password = password;
    }

    public UserData getUserData(){
        return new UserData(id, name, email, password);
    }

}

 

위와같이 Enum을 만들어 놓고 중복되는 부분에서 호출만해주면 된다. 이를 통해 고정 데이터를 관리할 수 있고, 하드코딩으로 인한 문제도 막을 수 있다.

@Test
@DisplayName("UserNotFoundException 예외를 던진다")
void throwsUserNotFoundException() {
	UserData userData = UserFixture.UPDATE_USER.getUserData();

	assertThatThrownBy(() -> userService.updateUser(INVALID_ID, userData))
		.isInstanceOf(UserNotFoundException.class);
}

 

3. 느낀점

 코드를 짜면서 들었던 찝찝함, 궁금증들을 모두 풀수있어 좋았다. Validation의 경우도 실무에서 적용해보았으나 그 방식에 대해 '이렇게 하는게 과연 맞나?' 라는 찝찝함도 이번 기회를 통해 해소할 수 있었다. 또한 멘토분께서 던져주시는 용어들에 대해 알아가는 과정이 너무 좋았다.

  사실 테스트 코드를 작성하는 건 아직 익숙하지 않다. 먼저 테스트코드를 작성하고 리팩토링하고 로직을 적용하는 방식이 너무 생소하지만 계속 하다보면 이것도 몸에 배겠지? 예전 자바지기님의 TDD 강의를 잠깐 들은 적이 있는데 그분께서 하신 말씀이 생각난다. TDD는 운동처럼 계속 해야한다고, 하다보면 자연스럽게 하게된다고.

반응형
반응형

1. 과제

 지금까지 배운 내용을 토대로 '고양이 장난감 가게' 어플리케이션을 개발하라.

 

2. 배운점

2.1. 계층별 단위테스트 방법

 - 계층별 단위테스트 방법을 알게되었다. 일반적으로 각 계층은 하위 계층에 의존적이다. 클린 아키텍쳐로 구성한 고양이 장난감 가게는 Web,  Controllers, Use Cases, Entites 계층으로 구성되었으며, 계층별 단위테스트 방법을 익혔다.

출처 :&nbsp;http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

1) Web

 mockMvc 와 Mock Service(Use Cases)로 테스트하였다. mockMvc는 서블릿 컨테이너의 구동 없이 HTTP 서블릿 요청과 응답을 제공하는 유틸리티 클래스이고, Mock Service는 Controller가 의존하는 Service를 Mocking 한 객체이다. Web 어플리케이션의 REST API 호출 테스트가 목적이기에 실제 Service 로직을 확인할 필요가 없으므로 Mocking한다.

 

2) Controllers

 Controller와 Mock Service(Use Cases)로 테스트하였다. 위와 마찬가지로 Controller 테스트가 목적이기에 Service를 Mocking 하였다. Controller가 의존하는 Service에 대해서는 메서드 호출 여부만 확인하면 된다. 확인이 필요한 메서드는 verify 메서드를 통해 확인하였다.

 초기 테스트 코드에는 Controller가 호출하는 모든 Service 메서드에 대해 verify 검증을 하였으나, Mock Service의 메서드에 Stubbing을 하여 응답 지정하고, 테스트를 통해 이 값을 받는 행위 자체가 Service의 메서드를 호출한 것이므로 메서드 검증을 할 필요가 없었다. 해서 응답 값이 없거나, 확인이 어려운 메서드에 대해서만 verify로 검증하였다.

 

3) Use Cases(Service or Application)

 Service와 Mock Repository로 테스트하였다. Service 테스트가 목적이기에 의존하는 Repository를 Mocking하였다. 마찬가지로 응답값이 없거나, 확인이 어려운 메서드에 대해서만 verify로 검증하였다.

 

4) Entities

 Repository와 서버 방식의 h2 DB로 테스트하였다. JPA를 사용하는 경우 JPA에서 지원하는 CrudRepository 같은 클래스를 상속받아 가져다 쓰므로 사실 검증할 필요가 없다. 단, 직접 쿼리를 작성할 경우에는 검증이 필요하다.

 

3. 느낀점

 비록 매우 단순하지만 클린 아키텍쳐가 적용된 프로그램의 단위 테스트코드를 작성해봄으로써 테스트코드에 대한 메커니즘을 이해할 수 있었다.

 필자는 이번 과제 중 Repository에 대한 테스트가 계속 실패하는 이슈가 있었다. 도저히 이해가 되지 않아 필자가 모르는 JPA의 영속성 컨텍스트 이슈로 판단하였고, 멘토님께 도움을 요청하였다.

 원인은 단순 테스트 데이터를 Insert하는 부분이었으며, 인메모리가 아닌 실제 DB 서버 방식으로 변경 후 디버깅하며 DB 데이터를 확인한 결과 필자가 예상하지 못한 데이터가 들어있었다. 데이터가 이렇게 들어간 원인도 정말 너무 단순했다.

 멘토님의 피드백 중 Repository 테스트 방식에 대해 언급해주신 내용이 있다. DB를 인메모리 방식의 h2를 많이 사용하나 개발자가 생각한 동작과 다를 수 있다는 점에서 실제 DB 서버와 연결하여 사용하는 것을 권장한다는 것. 이 내용이 사실 와닿지 않았지만, 내가 한 짓을 통해 단박에 이해하게 되었다! 5주차도 열심히!

반응형

+ Recent posts