본문 바로가기
챕터정리방

[3장] 템플릿

by jaee_ 2021. 12. 17.

3장 템플릿

1. 다시 보는 초난감 DAO

🤷‍♂️ 템플릿이란 ?

바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다.

예외처리 기능을 갖춘 UserDao의 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) {
            throw e;    // 예외가 발생했을 때 부가적인 작업을 해줄 수 있도록 catch블록을 둔다. 
        } finally {
            if (ps != null) {
                try {
                    ps.close();     // 리소스 반환
                } catch (SQLException e) {}
            }
            if (c != null) {
                try {
                    c.close();       // 리소스 반환
                } catch (SQLException e) {}
            }
        }

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

2. 변하는 것과 변하지 않는 것

템플릿 메소드 패턴을 적용

🤷‍♂️템플릿 메소드 패턴이란?

상속을 통해 기능을 확장해서 사용하는 부분이다. 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분을 추상 메소드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것이다.

위의 UserDao의 deleteAll() 메소드에 템플릿 메소드 패턴을 적용해보자.
우선 변하는 부분을 추출해서 추상메소드로 따로 만들어보자. 추상메소드를 가지게 되는 UserDao 역시 추상클래스로 변경해주자.

abstract protected PreparedStatement makeStatement (Connection c) throws SQLException;

그리고 이를 상속하는 서브클래스를 만들고 그 클래스에서 위의 추상메소드를 구현하자.

// makeStatement()를 구현한 UserDao 서브클래스
public class UserDaoDeleteAll extends UserDao {
    protected PreparedStatement makeStatement (Connection c) throws SQLException {
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}

이와 같이 적용하면 Connection과 PrepareStatement의 리소스를 필요로 하는 모든 메소드들은 슈퍼클래스로 정의한 UserDao를 상속받게될 것이다. 그렇다면 다음과 같은 구조가 된다.

하지만 이처럼 템플릿 메소드 패턴을 적용하면 DAO 로직마다 상속을 통해야 한다는 단점이 존재한다. JDBC 을 필요로하는 로직이 10개라면 10개의 클래스를 만들어 상속받아야한다는 것이다.

따라서 이는 유연성이 떨어진다.


3. JDBC 전략 패턴의 최적화

전략 패턴을 적용

🤷‍♂️전략 패턴이란?

오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 것이다.
개방 폐쇄 원칙(OCP)을 잘 지키는 구조이면서도 템플릿 메소드 패턴보다 유연화고 확장성이 뛰어나다. OCP 관점에서 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식이다.

위 그림에서 좌측에 있는 Context의 contextMethod()에서 일정한 구조를 가지고 동작하다가 특정 확장 기능은 Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임하는 것이다.

UserDao 클래스에 위의 전략을 적용하면 deleteAll()은 JDBC를 이용하여 DB를 업데이트 하는 작업이라는 변하지 않는 맥락(context)을 갖는다. PreparedStatement를 생성하는 외부 기능이 전략이라고 볼 수 있다.

UserDao의 전략 인터페이스를 만들기 위해선 DB 커넥션을 전달받아야하고, 전달받은 DB 커넥션을 통해 만들어진 PreparedStatement을 반환해야한다.

// statementStrategy 인터페이스
public interface StatementStrategy {
    PreparedStatement makePreparedStatement (Connection c) throws SQLException;
}

위 인터페이스를 상속해서 PreparedStatement를 생성하는 클래스를 만들어보자.

public class DeleteAllStatement implements StatementStrategy {
    PreparedStatement makePreparedStatement (Connection c) throws SQLException {
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}
// 전략패턴을 따라 DeleteAllStatement가 적용된 delelteAll() 메소드
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 statementStrategy = new DeleteAllStatement(); // 전략패턴 적용
            ps = statementStrategy .makePreparedStatement(c)

            ps.executeUpdate();
        } catch (SQLException e) {
            throw e;    // 예외가 발생했을 때 부가적인 작업을 해줄 수 있도록 catch블록을 둔다. 
        } finally {
            if (ps != null) {
                try {
                    ps.close();     // 리소스 반환
                } catch (SQLException e) {}
            }
            if (c != null) {
                try {
                    c.close();       // 리소스 반환
                } catch (SQLException e) {}
            }
        }

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

    }    
}

전략패턴을 적용하긴 했지만 컨텍스트 안에서 구체적인 DeleteAllStatement를 사용하도록 고정이 되어있는 부분이 신경쓰인다! OCP에 잘 들어맞는다고 할 수 없다! 컨텍스트에 해당하는 부분을 메소드로 따로 빼고 사용할 전략을 Client(사용할 메소드)가 입력하게끔 바꿔보자.

// 메소드로 분리한 context (try/catch/finally)
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) {
        throw e;    
    } finally {
        if (ps != null) {
            try {
                ps.close();     
            } catch (SQLException e) {}
        }
        if (c != null) {
            try {
                c.close();      
            } catch (SQLException e) {}
        }
    }
}
// 변경된 deletAll() 메소드
public void deleteAll() throws SQLException {
    StatementStrategy statementStrategy = new DeleteAllStatement(); // 선정한 전략 클래스의 오브젝트 생성
    jdbcContextWithStatementStrategy(statementStrategy); // 컨텍스트 호출 전략 오브젝트 전달
}    

조금 더 개선해보자.


중첩클래스를 적용해서 메소드마다 매번 클래스를 만들어야 하는 부분을 개선해보자.

🕵🏻‍♂️ 중첩클래스

다른 클래스 내부에 정의되는 클래스를 중첨 클래스(nested class) 라고 한다. 중첩 클래스는 독립적으로 오브젝트로 만들어질 수 있는 스태틱 클래스(static class)와 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스(inner class)로 구분된다.

내부 클래스는 다시 범위(scope)에 따라 세가지로 구분된다. 멤버 필드처럼 오브젝트 레벨에 정의되는 멤버 내부 클래스(member inner class)와 메소드 레벨에 정의되는 로컬 클래스(local class), 그리고 이름을 갖지 않는 익명 내부 클래스(anonymous inner class)다. 익명 내부 클래스의 범위는 선언된 위치에 따라서 다르다.

🕵🏻‍♂️ 익명 내부 클래스

익명 내부 클래스(anonymous inner class)는 이름을 갖지 않는 클래스다. 클래스 선언과 오브젝트 생성이 결합된 형태로 만들어지며 , 상속할 클래스나 구현할 인터페이스를 생성자 대신 사용해서 다음과 같은 형태로 만들어 사용한다. 클래스를 재사용할 필요가 없고, 구현한 인터페이스 타입으로만 사용할 경우에 유용하다.

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

 

// 익명 내부 클래스를 적용한 deleteAll() 메소드
public void deleteAll() throws SQLException {
    jdbcContextWithStatementStrategy{
        new StatementStrategy() {
            PreparedStatement makePreparedStatement (Connection c) throws SQLException {
                return c.prepareStatement("delete from users");
            }
        }
    };
}

전략패턴의 관점에서 볼 때 UserDao 의 메소드가 클라이언트, 익명 내부 클래스로 만들어진 것이 개별적인 전략, jdbcContextWithStatementStrategy() 메소드가 컨텍스트라 볼 수 있다.

 

4. 컨텍스트와 DI

전략 패턴의 구조로 보자면 UserDao의 메소드가 클라이언트이고, 익명 내부 클래스로 만들어지는 것이 개별적인 전략이고, jdbcContextWithStatementStrategy() 메소드는 컨텍스트다. JDBC의 일반적인 작업 흐름을 담고있는 jdbcContextWithStatementStrategy()를 UserDao 클래스 밖으로 독립시켜 모든 DAO가 사용할 수 있게 해보자.

클래스 분리

분리해서 만들 클래스의 이름을 JdbcContext로 하고 UserDao에 있던 컨텍스트 메소드는 workWithStatementStratey()라고 하자. JdbcContext는 DataSource에 의존하고 있으므로 DataSource 타입 빈을 DI 받을 수 있게 해줘야 한다.

package springbook.user.dao;
...
public class JdbcContext {
    private DataSource dataSource;		//DataSource타입 빈을 DI 받을 수 있도록 준비

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

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

        try {
            c = this.dataSource.getConnection();
            ps = stmt.makePreparedStatement(c);
            ps.executeUpdate();
        } catch(SQLException e) {
            throw e;
        } finally {
            if(ps != null) { try { ps.close(); } catch(SQLException e) {} }
            if(c != null) { try { c.close(); } catch(SQLException e) {} }
        }
    }
}

UserDao가 분리된 JdbcContext를 DI 받아서 사용할 수 있게 만든다.

public class UserDao {
    ...
    private JdbcContext jdbcContext;	

    public void setJdbcContext(jdbcContext jdbcContext) {	//jdbcContext를 DI 받도록 한다.
        this.jdbcContext = jdbcContext;
    }

    public void add(final User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(
                new StatementStrategy() {...}
        );
    }

    public void deleteAll() throws SQLException {
        this.jdbcContext.workWithStatementStrategy(	//DI받은 jdbcContext의 컨텍스트
                new StatementStrategy() {...}		//메소드를 사용하도록 한다.
        );
    }
}

빈 의존관계 변경

UserDao는 이제 JdbcContext에 의존하고 있다. JdbcContext는 인터페이스가 아닌 구체 클래스다. 스프링의 DI는 인터페이스를 사이에 두고 사용하는게 목적이지만 이 경우 JdbcContext는  JDBC 컨텍스트를 제공해주는 서비스 오브젝트일 뿐이고 구현 방법이 바뀌진 않는다. 따라서 UserDao와 JdbcContext는 인터페이스를 사이에 두지 않고 DI를 적용한 특별한 구조가 된다.

스프링의 빈 설정은 런타임 시에 만들어지는 오브젝트 레벨의 의존관계에 따라 정의된다. 기존에는 UserDao 빈이 dataSource 빈을 직접 의존했지만 이제 jdbcContext 빈이 사이에 끼게 된다.

JdbcContext가 적용된 빈 오브젝트 관계

스프링 빈으로 DI

인터페이스를 사용해서 클래스를 자유롭게 변경할 수 있게 하지는 않았지만, JdbcContext와 UserDao가 DI 구조로 만들어야 하는 이유는 다음과 같다.

  1. JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되기 때문이다.
    • JdbcContext는 JDBC 컨텍스트 메소드를 제공해주는 서비스 오브젝트로서의 의미만 있어 싱글톤으로 등록해 여러 오브젝트에서 공유하는 것이 이상적이다.
  2. JdbcContext가 DI를 통해 다른 빈에 의존하고 있기 때문에 스프링 빈으로 등록돼야 한다.
    • jdbcContext는 dataSource 프로퍼티를 통해 DataSource 오브젝트를 주입받도록 되어 있다.
    • DI를 위해서는 주입받는 쪽, 주입하는 쪽 모두 스프링 빈으로 등록돼야 한다.

코드를 이용하는 수동 DI

JdbcContext를 스프링 빈으로 등록하지 않고 UserDao 내부에서 직접 DI를 적용하는 방법을 사용해보자. 이 방법을 쓰려면 싱글톤으로 만드려는 것은 포기해야 한다. 대신 DAO마다 하나의 JdbcContext 오브젝트를 갖고 있게 하자. → 1번 이유 해결

JdbcContext를 스프링 빈으로 등록하지 않았으므로 다른 누군가가 JdbcContext의 생성과 초기화를 해야 한다. UserDao가 제어권을 갖게 하자.

UserDao에게 DataSource를 DI 받도록 하여 임시로 DI 컨테이너처럼 동작하게 한다. → 2번 이유 해결

재구성된 빈 의존관계는 다음과 같다.

코드를 통한 JdbcContext DI 구조

UserDao는 이제 JdbcContext를 외부에서 주입받지 않으니 setJdbcContext()는 제거하고 setDataSource() 메소드를 수정한다.

JdbcContext 생성과 DI 작업을 수행하는 setDataSource() 메소드

public class UserDao {
    ...
    private JdbcContext jdbcContext;

    public void setDataSource(DataSource dataSource) {	//수정자 메소드면서 JdbcContext에 대한
        this.jdbcContext = new jdbcContext();		//생성, DI 작업을 수행
        this.jdbcContext.setDataSource(dataSource); //의존 오브젝트 주입(DI)
        this.dataSource = dataSource; //아직 JdbcContext를 적용하지 않은 메소드를 위해 저장
    }
}

이 방법의 장점은 굳이 인터페이스를 두지 않아도 될 만큼 긴밀한 관계인 DAO 클래스와 JdbcContext를 따로 빈으로 분리하지 않고 내부에서 직접 만들어 사용하면서도 다른 오브젝트에 대한 DI를 적용할 수 있다는 점이다.

5. 템플릿과 콜백

지금까지 UserDao와 StatementStrategy, JdbcContext를 이용해 만든 코드는 일종의 전략 패턴이 적용된 것이다. 전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식으로, 스프링에서는 템플릿/콜백 패턴이라고 부른다.

  • 템플릿 : 전략 패턴의 컨텍스트
  • 콜백 : 익명 내부 클래스로 만들어지는 오브젝트, 템플릿 안에서 호출되는것이 목적

템플릿/콜백의 특징

콜백은 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문에 단일 메소드 인터페이스를 사용한다.

일반적으로 콜백은 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스로 만들어진다.

콜백 인터페이스의 메소드 파라미터는 템플릿에서 만들어지는 컨텍스트 정보를 받을 때 사용된다.

  • JdbcContext에서 템플릿인 workWithStatementStrategy() 내에서 생성한 Connection 오브젝트를 콜백 메소드인 makePreparedStatement()를 실행할 때 파라미터로 넘긴다.

템플릿/콜백의 작업 흐름

  • 클라이언트는 템플릿 안에서 실행될 로직을 담은 콜백 오브젝트를 만들고 콜백이 참조할 정보를 제공한다. 콜백은 템플릿의 메소드를 호출할때 파라미터로 전달된다.
  • 템플릿은 Workflow를 따라 작업을 진행하다가 내부에서 생성한 참조정보를 가지고 콜백 오브젝트의 메소드를 호출한다. 콜백은 클라이언트 메소드에 있는 정보와 템플릿이 제공한 참조정보를 이용해 작업을 수행하고 결과를 템플릿에 돌려준다.
  • 템플릿은 콜백이 돌려준 정보를 사용해 작업을 마저 수행한다.

일반적인 DI(템플릿에 인스턴스 변수를 만들어두고 사용할 의존 오브젝트를 수정자 메소드로 받아서 사용)와 달리 매번 메소드 단위로 사용할 오브젝트를 새로 전달받는다.

콜백 오브젝트가 내부 클래스로써 자신을 생성한 클라이언트 메소드 내의 정보를 직접 참조한다.

클라이언트와 콜백이 강하게 결합된다.

JdbcContext에 적용된 템플릿/콜백

UserDao, JdbcContext, StatementStrategy 코드에 적용된 템플릿/콜백 패턴을 살펴보자.

템플릿과 클라이언트가 메소드 단위인 것이 특징이다.

콜백의 분리와 재활용

복잡한 익명 내부 클래스의 사용을 최소화해보자. JDBC try/catch/finally에서 적용한 방법을 UserDao 메소드에 적용해 코드를 분리, 재사용한다면 간결하게 만들 수 있다.

deleteAll() 메소드에서 SQL 문장만 파라미터로 받아 바꿀 수 있게 하고 메소드 내용 전체를 분리해 별도의 메소드로 만든다.

public void deleteAll() throws SQLException {
    executeSql("delete from users"); 	//변하는 sql 문장

}

private void executeSql(final String query) throws SQLException {
    this.jdbcContext.workWithStatementStrategy(
        new StatementStrategy() { 	//변하지 않는 콜백 클래스 정의와 오브젝트 생성
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                return c.prepareStatement(query);
                }
        }
    );
}

콜백과 템플릿의 결합

exequteSql() 메소드를 DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨보자. 메소드 접근자를 public으로 바궈 외부에서 접근이 가능하게 한다.

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

UserDao의 메소드도 다음과 같이 수정한다.

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

하나의 목적을 위해 모인 응집력이 강한 코드들이기 때문에 한 군데 모여 있는 게 유리하다.

구체적인 구현과 내부 전략 패턴, DI 등은 감춰두고 외부에는 단순한 메소드만 노출해주는 것이다.

테스트와 try/catch/finally

간단한 템플릿/콜백 예제를 만들어보자. 1, 2, 3, 4 네 개의 숫자를 담고 있는 numbers.txt 파일을 준비한다. numbers.txt 파일 경로를 주면 10을 돌려주도록 만들면 된다.

package springbook.learningtest.template;
...
public class CalcSumTest {
    @Test
    public void sumOfNumbers() throws IOException {
        Calculator calculator = new Calculator();
        int sum = calculator.calcSum(getClass().getResource("numbers.txt").getPath());
        assertThat(sum, is(10));
    }   
}
package springbook.learningtest.template;
...
public class Calculator {
    public Integer calcSum(String filepath) throws  IOException {
        BufferedReader br = null;

        try {
            br = new BufferedReader(new FileReader(filepath));
            Integer sum = 0;
            String line = null;
            while((line = br.readLine()) != null) {
                sum += Integer.valueOf(line);
            }
            return sum;
        } catch (IOException e) {
            System.out.println(e.getMessage());
            throw e;
        } finally {		 // BufferedReader 오브젝트가 생성되기 전에 예외가 발생할 수 있으므로
            if(br != null) {	 //null 체크를 반드시 한다.
                try {		 // 한 번 연 파일은 반드시 닫아준다.
                    br.close();
                }
                catch(IOException e) {
                    System.out.println(e.getMessage());
                }
            }
        }
    }
}

중복의 제거와 템플릿/콜백 설계

만약 곱하기 기능을 추가해야 한다면? 템플릿/콜백 패턴을 적용해보자. 템플릿에 담을 반복되는 작업 흐름을 살펴보고 템플릿이 콜백에게 전달해줄 내부 정보는 무엇인지, 콜백이 템플릿에게 돌려줄 내용은 무엇인지도 생각해보자.

BufferdReader를 만들어 콜백에게 전달해주고 콜백이 각 라인을 읽어 알아서 처리한 후에 최종 결과만 템플릿에게 돌려주도록 한다.

BufferedReader를 전달받는 콜백 인터페이스

public interface BufferedReaderCallback {
    Integer doSomethingWithReader(BufferedReader br) throws IOException;
}

템플릿 부분을 메소드로 분리해보자. 템플릿에서는 BufferedReaderCallback 인터페이스 타입의 콜백 오브젝트를 받아 적절한 시점에 실행해주면 된다.

BufferedReaderCallback을 사용하는 템플릿 메소드

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

        try {
            br = new BufferedReader(new FileReader(filepath));
            // 콜백 오브젝트를 호출한다. 
            // 템플릿에서 만든 컨텍스트 정보(BufferedReader)를 전달하고 콜백의 결과를 받는다.
            int ret = callback.doSomethingWithReader(br);
            return ret;
        } catch (IOException e) {
            System.out.println(e.getMessage());
            throw e;
        } finally {
            if(br != null) {
                try { br.close(); }
                catch(IOException e) { System.out.println(e.getMessage()); }
            }
        }
}

준비된 fileReadTemplate()을 사용하도록 calcSum() 메소드를 수정한다.

템플릿/콜백을 적용한 calcSum() 메소드

public class Calculator {
    // 템플릿을 제외한 나머지 코드를 BufferedReaderCallback 인터페이스로 만든 익명 내부 클래스에 옮긴다.
    public Integer calcSum(String filepath) throws IOException {
        BufferedReaderCallback sumCallback = 
            new BufferedReaderCallback() {
                public Integer doSomethingWithReader(BufferedReader br) throws IOException {
                    Integer sum = 0;
                    String line = null;
                    while((line = br.readLine()) != null) {
                        sum += Integer.valueOf(line);
                    }
                    return sum; 
            }       
        };
        //처리할 파일의 경로와 익명 내부 클래스 오브젝트를 템플릿에 전달한다.
        return fileReadTemplate(filepath, sumCallback);
    }
}

이제 곱하기 메소드도 이 템플릿/콜백을 이용해 만든다. 그 전에 테스트를 만들어보자.

@Before 메소드에서 미리 픽스처로 사용할 클래스의 오브젝트를 만들어둔다.

새로운 테스트 메소드를 추가한 CalcSumTest

package springbook.learningtest.template;
...
public class CalcSumTest {
    Calculator calculator;
    String numFilepath;

    @Before
    public void setUp() {
        this.calculator = new Calculator();
        this.numFilepath = getClass().getResource("numbers.txt").getPath();
    }

    @Test
    public void sumOfNumbers() throws IOException {
        assertThat(calculator.calcSum(this.numFilepath), is(10));
    }   
	
    @Test
    public void multiplyOfNumbers() throws IOException {
        assertThat(calculator.calcMultiply(this.numFilepath), is(24));
    } 
}

곱을 계산하는 기능을 가진 calcMultiply() 메소드

public class Calculator {
    public Integer calcMultiply(String filepath) throws IOException {
        BufferedReaderCallback multiplyCallback = 
            new BufferedReaderCallback() {
                public Integer doSomethingWithReader(BufferedReader br) throws IOException {
                    Integer multiply = 1;
                    String line = null;
                    while((line = br.readLine()) != null) {
                        multiply += Integer.valueOf(line);
                    }
                    return multiply; 
            }       
        };
        return fileReadTemplate(filepath, multiplyCallback);
    }

    public Integer calcSum(String filepath) throws IOException {
       ...
        return fileReadTemplate(filepath, sumCallback);
    }

    public Integer fileReadTemplate(String filepath, BufferedReaderCallback callback) throws  IOException {
       ...
    }
}

템플릿/콜백의 재설계

calcSum()과 calcMultiply()는 아주 유사하다.

템플릿과 콜백을 찾아낼 때는 변하는 코드의 경계를 찾고 그 경계 사이에 주고받는 일정한 정보가 있는지 확인하면 된다.

라인별 작업을 정의한 콜백 인터페이스

package springbook.learningtest.template;
...
public interface LineCallback {
    Integer doSomethingWithLine(String line, Integer value);
}

LineCallback은 파일의 각 라인과 현재까지 계산한 값을 넘겨주도록 되어 있다. 그리고 새로운 결과를 리턴 값을 통해 다시 전달받는다.

LineCallback을 사용하는 템플릿

public Integer lineReadTemplate(String filepath, LineCallback callback, int initVal) throws  IOException {
      BufferedReader br = null;

      try {
          br = new BufferedReader(new FileReader(filepath));
          // 초기 값을 저장한다.
          int res = initVal;
          // 콜백에 있던 루프를 돌면서 파일을 읽어오는 것도 템플릿이 담당한다.
          while((line = br.readLine()) != null) {
              // 각 라인의 내용을 계산하는 작업만 콜백에게 맡긴다.
              res = callback.doSomethingWithLine(line, res);
              //콜백이 계산한 값을 저장해뒀다가 다음 라인 계산에 다시 사용한다.
          }
          return ret;
      } catch (IOException e) {
          System.out.println(e.getMessage());
          throw e;
      } finally {
          if(br != null) {
              try { br.close(); }
              catch(IOException e) { System.out.println(e.getMessage()); }
          }
      }
}

템플릿에 각 라인을 읽는 작업이 추가됐고 결과를 담을 변수를 초기화할 값도 전달받게 만들었다.

이렇게 수정한 템플릿을 사용하는 코드를 만들어보자.

lineReadTemplate()을 사용하도록 수정한calcSum(), calcMultiply() 메소드

public Integer calcSum(String filepath) throws IOException {
      LineCallback sumCallback = new LineCallback() {
            public Integer doSomethingWithLine(String line, Integer value) {
                return value + Integer.valueOf(line);
            }};
      return lineReadTemplate(filepath, sumCallbac, 0);
}

public Integer calcMultiply(String filepath) throws IOException {
       BufferedReaderCallback multiplyCallback = new LineCallback() {
            public Integer doSomethingWithReader(String line, BufferedReader br) {
                return multiply * Integer.valueOf(line); 
            }       
        };
      return lineReadTemplate(filepath, multiplyCallback, 1);
}

스프링의 JdbcTemplate

JdbcContext를 버리고 스프링이 제공하는 더 편리한 JdbcTemplate을 사용해보자.

update()

deleteAll()에 처음 적용했던 콜백은 StatementStrategy 인터페이스의 makePreparedStatement() 메소드다. 이에 대응되는 JdbcTemplate의 콜백은 PreparedStatementCreator 인터페이스의 createPreparedStatement() 메소드다.

PreparedStatementCreator 타입의 콜백을 받아 사용하는 JdbcTemplate의 템플릿 메소드는 update()다.

내장 콜백을 사용하는 update()로 변경한 deleteAll() 메소드

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

queryForInt()

getCount()는 SQL 쿼리를 실행하고 ResultSet을 통해 결과 값을 가져오는 코드이다. 이때 사용할 수 있는 템플릿은 query() 메소드다.

queryForInt()를 사용하는 getcount()

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

queryForObject()

get() 메소드에 JdbcTemplate을 적용해보자. ResultSet의 결과를 User 오브젝트로 만들어 프로퍼티에 넣어줘야 한다. 이를 위해 ResultSetExtractor 콜백 대신 RowMapper 콜백을 사용하자.

queryForObject()와 RowMapper를 적용한 get() 메소드

public User get(String id) {
	return this.jdbcTemplate.queryForObject("select * from users where id = ?", new Object[] {id},
    	new RowMapper<User>(){  //ResultSet한 로우의 결과를 오브젝트에 매핑해주는 RowMapper 콜백
        	public User mapRow(ReulstSet 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;
            }
        });		
}

query()

getAll() 메소드를 추가해보자. get() 메소드는 하나의 로우를 User 오브젝트에 담았으니 여러 개라면 User 오브젝트의 컬렉션으로 만든다. List<User> 타입으로 돌려주도록 만들고 id 순으로 정렬해서 가져오도록 만들자.

먼저 테스트를 만든다. User 타입 오브젝트인 user1,2,3 세 개를 DB에 등록하고 getAll()을 호출하면 List<User> 타입으로 결과를 받아야 한다. 리스트의 크기는 3이어야 하고 user1,2,3와 동일한 내용을 가진 오브젝트가 id 순서대로 담겨 있어야 한다.

getAll()에 대한 테스트

@Test
public void getAll()  {
	dao.deleteAll();
	
	List<User> users0 = dao.getAll();
	assertThat(users0.size(), is(0));
	
	dao.add(user1); // Id: gyumee
	List<User> users1 = dao.getAll();
	assertThat(users1.size(), is(1));
	checkSameUser(user1, users1.get(0));
	
	dao.add(user2); // Id: leegw700
	List<User> users2 = dao.getAll();
	assertThat(users2.size(), is(2));
	checkSameUser(user1, users2.get(0));  
	checkSameUser(user2, users2.get(1));
	
	dao.add(user3); // Id: bumjin
	List<User> users3 = dao.getAll();
	assertThat(users3.size(), is(3));
	checkSameUser(user3, users3.get(0));  
	checkSameUser(user1, users3.get(1));  
	checkSameUser(user2, users3.get(2));  
}

private void checkSameUser(User user1, User user2) {
	assertThat(user1.getId(), is(user2.getId()));
	assertThat(user1.getName(), is(user2.getName()));
	assertThat(user1.getPassword(), is(user2.getPassword()));
}

query() 템플릿을 이용하는 getAll()

public List<User> getAll() {
	return this.jdbcTemplate.query("select * from users order by id",
			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;
				}
			});
}

queryForObject()는 쿼리의 결과가 로우 하나일 때, query()는 여러 개의 로우가 결과로 나올 때 사용한다.

query()의 리턴 타입은 List<T>다. query()는 제네릭 메소드로 타입은 파라미터로 넘기는 RowMapper<T> 콜백 오브젝트에서 결정된다.

템플릿/콜백 패턴과 UserDao

최종적으로 완성된 JdbcTemplate을 적용한 UserDao 클래스

public class UserDao {
	public void setDataSource(DataSource dataSource) {
		this.jdbcTemplate = new JdbcTemplate(dataSource);
	}
	
	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 add(final User user) {
		this.jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",
						user.getId(), user.getName(), user.getPassword());
	}

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

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

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

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

}

UserDao에는 User 정보를 DB에 넣거나 가져오거나 조작하는 방법에 대한 핵심적인 로직만 담겨 있다. 반면에 JDBC API를 사용하는 방식, 예외처리, 리소스의 반납, DB연결을 어떻게 가져올지에 관한 책임과 관심은 JdbcTemplate에게 있다.

'챕터정리방' 카테고리의 다른 글

[5장] 서비스 추상화  (0) 2021.12.29
[5장] 서비스 추상화  (0) 2021.12.26
[4장] 예외  (0) 2021.12.21
[2장] 테스트  (0) 2021.12.11
[1장] 오브젝트와 의존관계  (0) 2021.12.08

댓글