본문 바로가기

책/토비의 스프링 3.1(1권, 완)

3장 템플릿

3장 템플릿

1장 오브젝트와 의존관계 에서 다루었는 OCP를 다음과 같이 정의 내렸다.

그리고 템플릿이란


3.1 다시 보는 초난감 DAO

정상적인 JDBC 코드의 흐름을 따르지 않고 중간에 어떤 이유로든 예외가 발생했을 경우에도 사용한 리소스를 반드시 반환하도록 만들어야 한다.

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

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

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

위의 코드에서 Bold체 부분에서 예외가 발생하면 바로 메소드 실행이 중단된다.

그리고 바로 중단될 경우 ps.close(), c.close() 실행이 되지 않아 리소스가 반환되지 않을수 있다.

리소스 반환과 close()

Connection이나 PreparedStatement는 보통 풀(pool) 방식으로 운영된다. 미리 정해진 풀 안에 제한된 수의 리소스(Connection, Statement)를 만들어 두고 필요할 때 이를 할당하고 반환하면 다시 풀에 넣는 방식으로 운영된다.

만약 커넥션 풀에 새로 할당할 수 있는 커넥션이 없을때 요청을 하면 오류를 내며 서버가 죽는다.

  • ➕ 추가
    • SpringBoot를 사용할때 ORM기술로 JPA를 사용할 경우 구현체로 Hibernate를 사용하고 커넥션 풀 관리할때는 HikariCP를 사용한다. 그리고 JPA를 사용한다고 하여도 새로 할당할 Connection이 없음에도 불구하고 Connection을 달라고 요청을 할 경우 예외가 발생하며 서버가 죽는다.
    • 커넥션을 달라고 하고 기다리는 시간과 커넥션 최대 연결시간을 application.yaml 혹은 application.properties로 설정할 수 있다.

그리고 이러한 상황을 막기 위해 try-catch 구문을 이용해 리소스를 반환하도록 한다.

그럼에도 불구하고 여전히 2가지 문제점이 존재한다.

  1. 예외가 어느 시점에 발생하는 가에 따라서 Connection이나 PreparedStatement, ResultSet중 어떤 것의 close 메소드를 호출해야 할지 달라진다
    1. 만약 Connection을 가져올때 예외가 발생하면 Connection, PreparedStatement 둘다 null이기에 close를 호출하면 안된다.
    2. 만약 PreparedStatement를 호출할때 예외가 발생했다면 Connection 인스턴스만 close를 호출해주어야 한다.
    3. ResultSet은 항상 반환해야 하는 리소스이기 때문에 예외상황에서도 이 ResultSetclose 메소드가 반드시 호출되도록 만들면 된다.
  2. close메소드도 SQLException이 발생할 수 있다. 따라서 try-catch로 처리해줘야 한다.

3.2 변하는 것과 변하지 않는 것

위의 코드는 복잡한 try/catch/finally 블록이 2중으로 중첩되어 있고 모든 메소드마다 반복된다는 단점이 존재한다.

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


    **Connection c = null;
    PreparedStatement ps = null;

    try {
        c = dataSource.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) {} }
    }**

위의 코드를 변하는 부분과 변하지 않는 부분으로 분류를 하면 다음과 같다.

  1. 메소드 추출
     public void deleteAll() throws SQLException {
         ...
         try {
             c = dataSource.getConnection();
    
             ps = makeStatement(c);
    
             ps.executeUpdate();
         } catch (SQLException e) {
     }
    
     private PreparedStatement makeStatement(Connection c) throws SQLException {
         PreparedStatement ps;
         ps = c.preparedStatement("delete from user");
         return ps;
     }
    그러나 이 방법은 추출해낸 메소드를 다른 DAO 로직에서 재사용이 불가능하기 때문에 적절하지 못하다.
  2. 변하는 부분을 메소드로 추출하는 방법이다.
  1. 템플릿 메소드 패턴의 적용이전 코드의 makeStatement 메소드를 다음과 같이 추상 메서드로 작성한다.그리고 이를 상속해 다음과 같이 코드를 작성한다.그러나 템플릿 메소드를 이용한 코드 역시 문제가 존재한다.
    1. OCP는 준수하지만 DAO 로직마다 새로운 클래스를 생성해주어야 한다. 즉 메소드 하나마다 클래스를 생성해주어야 한다.
    2. 클래스를 설계하는 시점에서 확장구조가 고정되어 버린다. 따라서 그 관계에 대한 유연성이 떨어진다.
  2. public class UserDaoDeleteAll extend UserDao { protected PreparedStatement makeStatement(Connection c) throws SQLException { PreparedStatement ps = c.prepareStatement("delete from users"); return ps; } }
  3. abstract private PreparedStatement makeStatement(Connection c) throws SQLException
  4. 💡 템플릿 메소드 패턴은 상속을 통해 기능을 확장해서 사용하며 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메소드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것이다.
  5. 전략 패턴의 적용특정 확장기능은 Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임하는 것이다.
     public interface StatementStrategy {
         **PreparedStatement makePreparedStatement(Connection c) throws SQLException;**
     }
     public class DeleteAllStatement **implements StatementStrategy {
         public PreparedStatement makePreparedStatement(Connection c) throws SQLException    {
             PreparedStatement ps = c.prepareStatement("delete from users");
             return ps;**
         }
    
     }
     public void deleteAll() throws SQLException {
         ...
         try {
             c = dataSource.getConnection();
    
             **StatementStrategy strategy = new new deleteAllStatement();
             ps = strategy.makePreparedStatement(c);**
    
             ps.executeUpdate();
         } catch (SQLException e) {
         ...    
         }
     }
    그러나 위의 코드는 컨텍스트 안에 이미 구체적인 전략 클래스인 DeleteAllStatement를 사용하도록 고정되어 있다.즉 DI란 이러한 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조라고 볼 수 있다.
     public void deleteAll() throws SQLException {
         // 선정한 전략 클래스의 오브젝트 생성
         **StatementStrategy = new DeleteAllStatement();**
         // 컨텍스트 호출 전략 오브젝트 전달
         **jdbcContextWithStatementStrategy(st);**
     }
  6. // stmt는 클라이언트가 컨텍스트를 호출할 때 넘겨줄 파라미터이다 **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) {} } } }
  7. 이 문제를 해결하기 위해 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리시키면된다. 그리고 이는 의존성 주입을 하는 방법과 동일하다
  8. 위의 예제에서 예시를 들면 PreparedStatement를 만들어주는 외부 기능이 바로 전략 패턴에서 말하는 전략을 의미한다.
  9. 💡 전략패턴은 OCP를 잘 지키는 구조이면서 템플릿 메소드 패턴보다 유연하고 확장성이 뛰어나다. 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만들기 때문이다.

3.3 JDBC 전략 패턴의 최적화

만약 전략 클래스에 추가 정보가 필요할 경우 클라이언트로부터 필요한 객체를 받을 수 있도록 생성자를 통해 제공받거나 setter를 통해서 추가 정보를 얻을 수 있다.

public class AddStatement implements StatementStrategy{
    User user;

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

    public PreparedStatement makePreparedStatement(Connection c){
        ...
        ps.setString(1, user.getId());
        ps.setString(2, user.getName());
        ps.setString(3, user.getId());
        ...
    }
}
public void add(User user) throws SQLException {
    StatementStrategy st = new AddStatement(user);
    jdbcContextWithStatementStrategy(st);
}

여전히 위의 코드는 다음 두가지의 문제점이 존재한다.

  1. DAO 메소드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다는 한다.
  2. DAO 메소드에서 StatementStrategy에 전달한 User와 같은 부가적인 정보가 있을 경우, 이를 위해 오브젝트를 전달받는 생성자와 이를 저장해둘 인스턴스 변수를 번거롭게 만들어야 한다.
  • ➕중첩 클래스의 종류
    1. 독립적으로 오브젝트로 만들어질 수 있는 static 클래스
    2. 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스
      1. 오브젝트 레벨에 정의되는 멤버 내부 클래스
      2. 메소드 레벨에 정의되는 로컬 클래스
      3. 이름을 갖지 않는 익명 내부 클래스
  • 중첩클래스 : 다른 클래스 내부에 정의되는 클래스를 중첩 클래스라고 한다.

첫번째의 문제이기도 한 클래스 파일이 많아지는 문제는 StatementStrategy 전략 클래스를 매번 독립된 파일로 만들지 말고 UserDao 클래스 안에 내부 클래스로 정의해버리는 것이다.

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

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

        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 st = new AddStatement(user);
    jdbcContextWithStatementStrategy(st);
}

로컬 클래스는 선언된 메소드 내에서만 사용될 수 있다.

그리고 다음과 같은 장점을 얻을 수 있다.

  1. 클래스 파일을 줄일 수 있다.
  2. add() 메소드 안에서 PreparedStatemetn 생성 로직을 함께 볼 수 있으니 코드를 이해하기 쉽다.
  3. 로컬 클래스는 자신이 선언된 곳의 정보에 접근 할 수 있다.
    1. 만약 내부 클래스에서 외부의 변수를 사용하려면 반드시 final로 선언해줘야 한다.

그리고 다음과 같이 수정할 수 있다.

public void add(final User user) throws SQLException {
    class AddStatement implements 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());
            ps.setString(3, user.getPassword());

            return ps;
        }
    }

    StatementStrategy st = new AddStatement(user);
    jdbcContextWithStatementStrategy(st);
}

여기서 더 나아가 AddStatement는 add() 메소드에만 사용하기 때문에 익명 내부 클래스로 생성가능하다.

StatementStrategy st = 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());
            ps.setString(3, user.getPassword());

            return ps;
        }
}
jdbcContextWithStatementStrategy(st);

마지막으로 다음과 인라인 코드으로 줄일수 있다.

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());
                ps.setString(3, user.getPassword());

                return ps;
            }
        }
    );
}

3.4 컨테스트와 DI

지금까지 진행한 것으로 보아 클라이언트의 역할은 메소드, 내부 클래스로 만들어지는 것은 개별적인 전략, jdbcContextWithStatementStrategy() 메소드는 컨텍스트 이다.

그런데 JDBC의 일반적인 작업 흐름을 담고 있기에 jdbcContextWithStatementStrategy()는 다른 다른 DAO에서 사용 가능하다.

  1. 클래스분리
     public class UserDao {
         ...
         private JdbcContext jdbcContext;
    
         public void setJdbcContext(JdbcContext jdbContext){
             this.jdbcContext = jdbcContext;
         }
    
         public void add(final User user) throws SQLException {
             this.jdbcContext.workWithStatementStrategy(
                     new StatementStrategy() {...}
             );
         }
    
         public void deleteAll() throws SQLException {
             this.jdbcContext.workWithStatementStrategy(
                 new StatementStrategy() {...}
             );
         }
    
     }

    UserDao와 JdbcContext 사이에는 인터페이스를 사용하지 않고 DI를 적용했다. 그리고 이러한 방식은 런타임 시에 DI방식으로 외부에서 오브젝트를 주입해주는 방식을 사용하긴 하지만, 의존 오브젝트의 구현 클래스를 변경할 수는 없다.그러나 스프링의 DI는 IoC라는 개념을 포괄하기 때문에 결국 위의 코드도 DI의 기본을 따르고 있다고 볼 수 있다.
    1. JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되기 때문이다.
    2. JdbcContext는 JDBC 컨텍스트 메소드를 제공해주는 일종의 서비스 오브젝트이고 이는 싱글톤으로 등록되어 여러 오브젝트에서 공유해 사용되는 것이 이상적이기 때문이다.
    3. JdbcContext가 DI를 통해 다른 빈에 의존하고 있기 때문이다.
    4. 여기서 DI를 하기 위해서는 주입되는 오브젝트나 주입받는 오브제트 모두 스프링 빈이어야 한다. 또한 다른 빈을 DI받기 위해서라도 스프링 빈으로 등록되어야 한다.
  2. JdbcContext를 UserDao와 DI구조로 만들어야 할 이유는 다음과 같다.
  3. DI의 엄밀한 개념은 “인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않게 하고, 런타임 시에 의존할 오브젝트와의 관계를 동적으로 주입해주는 것”이다. 그러므로 엄밀하게 본다면 위의 코드는 DI라고 볼 수 없다.
  4. public class JdbcContext { 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 = 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) {} } } } }

3.5 템플릿과 콜백

복잡하지만 바뀌지 않는 일정한 패턴을 갖는 작업 흐름이 존재하고 그중 일부분만 자주 바꿔서 사용해야 하는 경우에 적합한 구조다. 그리고 스프링에서는 이를 템플릿/콜백 패턴이라고 부른다.

템플릿 : 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 가리킨다.

콜백: 실행되는 것을 목적으로 다른 오브젝트의 메소드에 전달되는 오브젝트를 말한다.


  • 여러 개의 메소드를 가진 일반적인 인터페이스를 사용할 수 있는 전략 패턴의 전략과 달리 템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용한다.
  • 콜백 인터페이스의 메소드에는 보통 파라미터가 있고 이 파라미터는 템플릿의 작업 흐름 중에 만들어지는 컨텍스트 정보를 전달받을 때 사용된다.
  • 템플릿이 사용할 콜백 인터페이스를 구현한 오브젝트를 메소드를 통해 주입해주는 DI작업이 클라이언트가 템플릿의 기능을 호출하는 것과 동시에 일어난다.
  • 일반적인 DI라면 템플릿에 인스턴스 변수를 만들어두고 사용할 의존 오브젝트를 수정자 메서드로 받아서 사용할 것이다.

➕전략 패턴과 템플릿 콜백 패턴

전략 패턴은 일반적으로 선 조립후 후 실행한다. 장점으로는 조립이 끝났다면 전략에 신경쓰지 않고 바로 수행하면 된다. 단점으로는 전략이 조립되었다면 이를 수정하기 어렵다.

템플릿 콜백 패턴은 파라미터에 콜백을 받아 실행한다. 장점으로는 실행시마다 콜백을 유연하게 변경해줄수 있다. 단점으로는 실행시마다 콜백을 계속 전략을 지정해주어야 한다는 단점이 존재한다.

그리고 템플릿 콜백 패턴에서는 익명 내부 클래스를 사용하기 때문에 상대적으로 코드를 작성하고 읽기가 불편하다. 그리고 이를 다음과 같이 해결할 수 있다.

public void deleteAll() throws SQLException {
        this.jdbcContext.workWithStatementStrategy(
            **new StatementStrategy() {
                public PreparedStatement makePreparedStatement(Connection c)
                        throws SQLException {
                    return c.prepareStatement("delete from users"); // 쿼리문만 변경되지 않는다.
                }
            }**
        );
    }
public void deleteAll() throws SQLException {
        this.jdbcContext.executeSql("delete from users");
}

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

그리고 executeSql메서드는 다른 DAO에서 동일하게 사용하가능하므로 jdbcContext로 옮겨도 된다.

발표

➕ 추가

템플릿 메소드 패턴의 적용

전략 패턴의 적용

➕전략 패턴과 템플릿 콜백 패턴

' > 토비의 스프링 3.1(1권, 완)' 카테고리의 다른 글

6장 AOP  (2) 2023.11.23
5장 서비스 추상화  (1) 2023.11.23
4장 예외  (1) 2023.11.02
2장 테스트  (1) 2023.10.05
1장 오브젝트와 의존관계  (0) 2023.09.27