스프링/토비의 스프링

토비의 스프링 3장 - 템플릿

Rudtjs 2023. 5. 14. 17:10
  • 템플릿이란? 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 활용할 수 있도록 하는 방법이다.

 

다시 보는 초난감 DAO

DB 커넥션처럼 제한적인 리소스를 공유해 사용하는 서버에서 동작하는 JDBC 코드는 예외처리가 반드시 필요하다.

예외가 발생하더라도 사용한 리소스를 반환해줘야 하기 때문이다.

       public void deleteAll() throws SQLException {
        Connection c = dataSource.getConnection();
                PreparedStatement ps = c.prepareStatement("delete from users");
                ps.executeUpdate();

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

언뜻 문제가 없어 보이지만, preparedStatement 처리하는 중 예외가 발생하면 자원을 반환하는 close() 메서드가 수행되지 않아 반환되지 않을 수 있다.

그래서 예외가 발생하더라도 리소스를 반환하도록 수정하여야 한다.

 

     public void deleteAll() throws SQLException {
        Connection c = null;
        PreparedStatement ps = null;

        try {
            c = getConnection();
            ps = c.prepareStatement("delete from users");
            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) {
            } 
            }             
        }
    }

예외 발생에 대해 잘 처리했지만, 그에 따라 코드가 많이 복잡해졌다.

 

변하는 것과 변하지 않는 것

복잡한 try/catch/finally 블록은 2중으로 중첩되어 있고 모든 메소드마다 반복될 것이다.

커넥션은 반환되지 않고 계속 쌓여가다 중단되는 일이 발생할 수도 있다.

 

이 문제의 핵심은 변하지 않는, 그러나 많은 곳에서 중복되는 코드와 로직에 따라 자꾸 확장되고 자주 변하는 코드를 잘 분리해 내는 작업이다.

 

분리와 재사용을 위한 디자인 패턴 적용

메소드 추출

public void deleteAll() throws SQLException {
    ...
    try {
        c = dataSource.getConnectin();
        ps = makeStatement(c);      //  변하는 부분을 메소드로 추출하고 변하지 않는 부분에서 호출한다.
        ps.executeUpdate();
    } catch (SQLException e) {...}
}

private PreparedStatement makeStatement(Connection c) throws SQLException {
    PreparedStatement ps;
    ps = c.preparedStatement("delete from users");
    return ps;
}

보통 메소드 추출 리팩토링을 적용하는 경우에는 분리시킨 메소드를 다른 곳에서 재사용할 수 있어야 하는데, 이건 반대로 분리시키고 남은 메소드가 재사용이 필요한 부분이고, 분리된 메소드는 DAO 로직마다 새롭게 만들어서 확장돼야 하는 부분이기 때문에 뭔가 반대로 되었다.

 

템플릿 메소드 패턴의 적용

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

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

이제 UserDao 클래스의 기능을 확장하고 싶을 때마다 상속을 통해 자유롭게 확장할 수 있다.

전략 패턴의 적용

오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다.

 

만들어볼 전략 패턴을 정리하자면 이렇다.

  • DB 커넥션 가져오기
  • PreparedStatement를 만들어줄 외부 기능 호출하기;
  • 전달받은 PreparedStatement 실행하기
  • 예외가 발생하면 이를 다시 메소드 밖으로 던지기
  • 모든 경우에 만들어진 PreparedStatement와  Connection 닫아주기

전략 패턴의 구조를 따라 이 기능을 인터페이스로 만들어두고 인터페이스의 메소드를 통해 PreparedStatement 생성 전략을 호출해 주면 된다.

public void deleteAll() throws SQLException {
    ...
    try {
        c = dataSource.getConnection();

        StatementStrategy strategy = new DeleteAllStatement();  //  전략 클래스가 DeleteAllStatement로 고정됨으로써 OCP 개방 원칙에 맞지 않게 된다.
        ps = starategy.makePreparedStatement(c);

        ps.executeUpdate();
    } catch (SQLException e) {...}
}

전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서 전략을 바꿔 쓸 수 있다는 것인데, 이렇게 컨텍스트 안에서 이미 구체적인 전략 클래스인 DeleteAllStatement를 사용하도록 고정되어 있다면 뭔가 이상하다.

 

DI 적용을 위한 클라이언트/컨텍스트 분리

전략 패턴에 따르면 Context가 어떤 전략을 사용하게 할 것인가는 Context를 사용하는 앞단의 Client가 결정하는 게 일반적이다. 

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) {}
    }
}

클라이언트로부터 StatementStrategy 타입의 전략 오브젝트를 제공받고 try/catch/finally 구조로 만들어진 컨텍스트 내에서 작업을 수행한다.

 

JDBC 전략 패턴의 최적화

코드의 개선이 많이 이루어졌지만 두 가지의 문제점이 있다.

  • 먼저 DAO 메소드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다는 점이다.
  • DAO 메소드에서 StatementStrategy에 전달할 User와 같은 부가적인 정보가 있는 경우, 이를 전달하고 저장해 둘 생성자와 인스턴스 변수를 번거롭게 만들어야 한다.

 

익명 내부 클래스

익명 내부 클래스는 선언과 동시에 오브젝트를 생성한다.

public void add(final User user) throws SQLException {
    jdbcContextWithStatementStrategy(
        new StatementStrategy() {
            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();
                ...
                return ps;
            }
        }
    );
}

 

컨텍스트와 DI

전략 패턴의 구조로 보자면 UserDao의 메소드가 클라이언트이고, 익명 내부 클래스로 만들어지는 것이 개별적인 전략이고, jdbcContextWithStatementStrategy() 메소드는 컨텍스트다.

public class JdbcContext {
    private DataSource dataSource;

    public void setDataSource(DataSource dataSource) {  //  DataSource 타입 빈을 DI 받을 수 있게 준비
        this.dataSource = dataSource;
    }

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

        try {...} 
        catch (SQLException e) {...}
        finally {...}
    }
}
public class UserDao {
    ...
    private JdbcContext jdbcContext;

    public void setJdbcContext(JdbcContext jdbcContext) {
        this.jdbcContext = jdbcContext;             //  jdbcContext를 Di받도록 만든다.
    }

    public void add(final User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(     //  DI 받은 JdbcContext의 컨텍스트 메소드를 사용하도록 변경한다.
            new StatementStrategy() {...}
        );
    }
}
스프링의 DI는 넓게 보자면 객체의 생성과 관계설정에 대한 제어권한을 오브젝트에서 제거하고 외부로 위임했다는 IoC라는 개념을 포괄한다.
 

템플릿과 콜백

전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식을 스프링에서는 템플릿/콜백 패턴이라고 부른다. 전략 패턴의 컨텍스트를 템플릿이라 부르고, 익명 내부 클래스로 만들어지는 오브젝트를 콜백이라고 부른다.

 

템플릿/ 콜백의 동작 원리

템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용한다.

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

템플릿 / 콜백의 응용

스프링은 단지 이를 편리하게 사용할 수 있도록 도와주는 컨테이너를 제공하고, 이런 패턴의 사용 방법을 지지해주는 것뿐이다. 스프링을 사용하는 개발자라면 당연히 스프링이 제공하는 템플릿/콜백 기능을 잘 사용할 수 있어야 한다. 스프링에 내장된 것을 원리도 알지 못한 채로 기계적으로 사용하는 경우와 적용된 패턴을 이해하고 사용하는 경우는 큰 차이가 있다.
고정된 작업 흐름을 갖고 있으면서 여기저기서 자주 반복되는 코드가 있다면, 중복되는 코드를 분리할 방법을 생각해보는 습관을 기르자
UserDao에서만 사용되기 아까운 재사용 가능한 콜백을 담고있는 executeSql() 메소드를 템플릿 클래스 안으로 옮길 수 있다.
public class JdbcContext {
    ...
    public void executeSql(final String query) throws SQLException {
        workWithStatementStrategy(
            new StatementStrategy() {...}
        );
    }
}

이로써 모든 DAO 메소드에서 executeSql() 메소드를 사용할 수 있게 됐다.

 

정리

  • 공유 리소스의 반환이 필요한 코드는 반드시 try/catch/finally 블록으로 관리한다.
  • 중복 코드는 분리하자. 외부에서도 사용될 수 있으면 클래스도 분리하라.
  • 컨텍스트는 빈으로 등록해서 DI 받거나, 수동으로(클라이언트 클래스에서) 직접 컨텍스트를 생성(+ 의존성 주입)하는 두 가지 방법으로 운용할 수 있다.
  • 템플릿/콜백 패턴은 컨텍스트 호출과 동시에 전략 DI를 수행하는 패턴이다.
  • 콜백에서도 일정 패턴이 반복된다면 그것마저 템플릿에 넣고 재활용할 수 있다.