본문 바로가기
프로그래밍/Spring

[토비의 스프링 3장] 템플릿

by Loper Lee 2022. 6. 9.

3장 템플릿

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

예외처리 기능을 갖춘 DAO

public void deleteAll() throws SQLException {
    Connection c = dataSource.getConnection();
    PreparedStatement ps = c.prepareStatement("DELETE FROM users WHERE 1=1");

        ps.close();
        c.close();
        ...
  • DBO 객체 내의 ReparedStatement를 처리하는 중 오류가 발생하면 Connection 반환이 정상적으로 이루어지지 않는다.
  • 일반적으로 DB풀에서 커넥션 수를 제한하는데, 정상적으로 반환되지않는 커넥션이 쌓일 경우 최악의 경우 프로그램이 중단될 수 있다.
public void deleteAll() throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;

    try {
        c = dataSource.getConnection();
        ps = c.prepareStatement("DELETE FROM users WHERE 1=1");
        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) {
            }
        }
    }
}

변하는 것과 변하지 않는 것

앞서 예외처리가 이루어진 Dao코드의 문제점은 복잡한 try/catch/finally 블록이 2중으로 충첩되어 나오는데다, 모든 메소드마다 반복된다.

  • 누군가 DAO로직을 수정하려고 했을 때 복잡한 try/catch/finally 블록 안에서 필요한 부분을 찾아서 수정해야하고, 언젠가 꼭 필요한 부분을 잘못 삭제해버리면 역시 같은 문제가 반복된다.
    언제터질지도 모르는 폭탄같은 코드가 되어버리는 것이다.
  • 테스트를 통해서 DAO마다 예외상황에서 리소스를 반납하는지 체크하면 되는거 아닌가?
    • 좋은 생각이긴 하지만 예외처리를 하는 코드는 테스트가 어렵고, 번거롭다.
    • 강제로 예외상황을 만들려면 특별히 개발한 Connection/PreparedStatement 구현 클래스가 필요하다.
    • 이런 기능의 구현은 쉽지않고, 구현하더라도 테스트 코드의 양이 엄청나게 늘어날 것이다.

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

  • 먼저 코드 내에서 변하는 부분과 변하지 않는 부분을 분리한다.
  • 메소드 추출
    • 가장 먼저 생각해볼 수 있는 방법이지만, 자주 바뀌는 부분을 메소드로 독립시켜봤자 분리시킨 메소드를 다른곳에서 이용할 수 없기 때문에 별 이득은 없다.
  • 템플릿 메소드 패턴의 적용
    • 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상메소드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는것이다.
    • 문제는 상속을 이용한 확장이 문제!
      • 상속 받은 정보를 부모 클래스로부터 Look-up 하여 살펴봐야 하기 때문에 인터페이스 보다 비싼 연산
      // 추상 메서드
      abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
      
      // 상속받아서 사용하기
      protected PreparedStatement makeStatement(Connection c) throws SQLException{
            PreparedStatement ps = c.prepareStatement("delete from users");
            return ps;
      }

전략패턴의 적용

  • OCP를 잘 지키는 구조면서도 템플릿 메소드보다 유연하고 확장성이 뛰어난것이 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략패턴
  • deleteAll() 메소드에서 변하지 않는 부분
    • DB 커넥션 가져오기
    • PreparedStatement를 만들어줄 외부 기능 호출하기
    • 전달받은 PreparedStatement 실행하기
    • 예외가 발생하면 이를 다시 메소드 밖으로 던지기
    • 모든 경우에 만들어진 PreparedStatement와 Connection을 적절히 닫아주기
    • StatementStrategy 인터페이스
      public class DeleteAllStatement implements StatementStrategy {
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException{
                    PreparedStatement ps = c.preparedStatement("delete from users");
                    return ps;
            }
      }
      public void deleteAll() throws SQLException {
            try{
                    c = dataSource.getConnection();
      
                    StatementStrategy strategy = new DeleteAllStatement();
                    ps = strategy.makePreparedStatement(c);
      
                    ps.excuteUpdate();
            } catch (SQLException e){
                    ...
      }
    • public interface StatementStrategy { PreparedStatement makePreparedStatement(Connection c) throws SQLExcpetion; }
    • 그런데 이렇게 되면 Context가 무엇을 실행할 지 컴파일 시점에 알고 있다. 템플릿 메소드 패턴과 동일한 문제를 가진 것이다

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

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

  • JDBC try/catch/finally코드를 클라이언트 코드인 StatementStrategy를 만드는 부분에서 독립시켜야 한다.

JDBC 전략 패턴의 최적화

전략 클래스의 추가 정보

등록을 위해서 사용하는 AddStatement 전략은 user 오브젝트를 제공 받아야한다.

public class AddStatement implements StatementStrategy {
        User user;

        public AddStatement(User user){
                this.user = user;
        }

        public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.preparedStatement("insert into users()")
                ps.setString(1,user.getId());
                ps.setString(2,user.getName());
                return ps;
        }
}

전략 패턴을 이용해 컨텍스트를 활용할 수 있으니 try/catch/finally로 범범된 코드를 만들다가 실수할 염려는 없어졌다.

전략과 클라이언트의 동거

현재 구조의 두 가지 불만이 있다.

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

로컬 클래스

클래스 파일이 많아지는 문제의 간단한 해결 방법으로 UserDao 클래스 안에 내부 클래스로 정의해버리는 것이다.

AddStatement가 사용될 곳이 add() 메소드뿐이라면, 사용하기 전에 바로 정의해서 쓰는것도 나쁘지 않다. 덕분에 클래스 파일이 하나 줄었고, add() 메소드 안에서 생성로직을 함께 볼 수 있으니 코드를 이해하기도 좋다.

다만 내부 클래스에서 외부의 변수를 사용할 때 외부 변수는 반드시 final로 선언해 줘야 한다.

public void add(final User user) throws SQLException {
        class AddStatement implements StatementStrategy {
                ...
        }
        StatementStrategy st = new AddStatement();
        jdbcContextWithStatementStrategy(st);
}

익명 내부 클래스

StatementStrategy st = new StatementStrategy() {
        public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                ...
        }
}

컨텍스트와 DI

jdbcContext의 분리

  • UserDao의 메소드는 클라이언트
  • 익명 내부 클래스로 만들어지는 것은 개별적 전략
  • jdbcContextWithStatementStrategy() 메소드는 컨텍스트

jdbcContextWithStatementStrategy()를 UserDao 클래스 밖으로 독립 ⇒ 모든 DAO가 사용가능

클래스 분리

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) {} }
    }
}
public class UserDao {
        ...
        private JdbcContext jdbcContext;

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

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

        public void deleteAll() throws SQLExcpetion {
                this.jdbcContext.workWithStatementStrategy( new StatementStrategy() {...} );
        }
}

JdbcContext의 특별한 DI

지금까지 적용했던 DI에서는 클래스 레벨에서 구체적인 의존관계가 만들어지지 않도록 인터페이스를 사용했다.

인터페이스를 적용했기 때문에 코드에서 직접 클래스를 사용하지 않아도 됐고, 그 덕분에 설정을 변경하는 것만으로도 얼마든지 다양한 의존 오브젝트 변경이 가능해짐!


템플릿과 콜백

전략 패턴을 이용한 위의 코드들은 복잡하지만, 바뀌지 않는 일정한 패턴을 갖는 작업 흐림이 존재하고 그중 일부분만 자주 바꿔서 아용해야 하는 경우에 적합한 구조다.

전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식이다. 이런 방식을 스프링에서는 템플릿/콜백 패턴이라고 부른다.

템플릿/콜백 특징

여러 개의 메소드를 가진 일반적인 인터페스를 사용할 수 있는 전략 패턴의 전략과 달리 템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용함

  • 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문
  • 콜백은 일반적으로 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스로 만들어짐

템플릿/콜백 패턴의 일반적 작업 흐름

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

콜백 재활용

public void deleteAll() throws SQLException {
    this.jdbcContext.workWithStatementStrategy(
        new StatementStrategy() {
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                return c.preparedStatement("delete from users");
            }
        }
    );
}
public void deleteAll() throws SQLException {
    executeSql("delete from users");
}
--- 분리
private void executeSql(final String query) throws SQLException {
    this.jdbcContext.workWithStatementStrategy(
        new StatementStrategy() {
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                return c.preparedStatement(query);
            }
        }
    );
}

마이크로 DI

  • DI의 가장 중요한 개념은 제3자의 도움을 통해 두 오브젝트 사이의 유연한 관계가 설정되도록 만드는 것
  • 일반적으로 DI는 의존관계에 있는 두 개의 오브젝트와 이 관계를 다이내믹하게 설정해주는 오브젝트 팩토리(DI 컨테이너), 그리고 이를 사용하는 클라이언트라는 4개의 오브젝트 사이에서 일어난다.
  • 심지어는 클라이언트와 DI관계에 있는 두 개의 오브젝트가 모두 하나의 클래스 안에 담길 수도 있다. ⇒ 이런경우 DI가 코드와 메소드 사이에서 일어나기도 한다.
  • DI의 장점을 단순화해서 IoC 컨테이너의 도움 없이 코드 내에서 적용한 경우를 마이크로 DI라고도 한다.

중첩 클래스의 종류

다른 클래스 내부에 정의되는 클래스를 중첩 클래스(nested class)라고 한다.

  • static class = 독립적으로 오브젝트로 만들어질 수 있는 클래스
  • inner class = 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스
    • member inner class = 멤버 필드처럼 오브젝트 레벨에 정의된다.
    • local class = 메소드 레벨에 정의된다.
    • anonymouse inner class = 이름을 갖지 않는 익명 클래스이다. 익명 내부 클래스의 범위는 선언된 위치에 따라서 다르다.
  • ⇒ 내부 클래스는 다시 범위(Scope에 따라서 세 가지로 구분된다.

템플릿

템플릿은 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 가리킨다. 템플릿 메소드 패턴은 고정된 틀의 로직을 가진 템플릿 메소드를 슈퍼클래스에 두고, 바뀌는 부분을 서브클래스의 메소드에 두는 구조로 이뤄진다.

콜백

콜백은 실행되는 것을 목적으로 다른 오브젝트의 메소드에 전달되는 오브젝트를 말한다. 자바에서는 메소드 자체를 파라미터로 전달할 방법이 없기 때문에 메소드가 담긴 오브젝트를 전달해야 한다. 그래서 펑서녈 오브젝트(functional object)라고도 한다.


정리

  • JDBC와 같은 예외가 발생할 가능성이 있으며 공유 리소스의 반환이 필요한 코드는 반드시 try/catch/finally 블록으로 관리해야 한다.
  • 일정한 작업 흐름이 반복되면서 그중 일부 기능만 바뀌는 코드가 존재한다면 전략 패턴을 적용한다. 바뀌지 않는 부분을 컨텍스트로, 바뀌는 부분은 전략으로 만들고 인터페이스를 통해 유연하게 전략을 변경할 수 있도록 구성한다.
  • 클라이언트 메소드 안에 익명 내부 클래스를 사용해서 전략 오브젝트를 구현하면 코드도 간결해지고 메소드의 정보를 직접 사용할 수 있어서 편리하다.
  • 컨텍스트가 하나 이상의 클라이언트 오브젝트에서 사용된다면 클래스를 분리해서 공유하도록 만든다.
  • 단일 전략 메소드를 갖는 전략 패턴이면서 익명 내부 클래스를 사용해서 매번 전략을 새로 만들어 사용하고, 컨텍스트 호출과 동시에 전략 DI를 수행하는 방식을 템플릿/콜백 패턴이라고 한다.
  • 콜백의 코드에도 일정한 패천이 반복된다면 콜백을 템플릿에 넣고 재활용하는 것이 편리하다.
  • 템플릿과 콜백의 타입이 다양하게 바뀔 수 있다면 제네릭스를 이용한다.
  • 템플릿은 한 번에 하나 이상의 콜백을 사용할 수도 있고, 하나의 콜백을 여러 번 호출할 수도 있다.
  • 템플릿/콜백을 설계할 때는 템플릿과 콜백 사이에 주고받는 정보에 관심을 둬야한다.