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

대연.

·

2021. 9. 27. 01:06

Source : yes24

  1. 해당 포스팅은 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
  2. 공부하면서 블로그를 참고하였는데, 책 내용을 그대로 정리하는데 그치는 글들이 절반이었습니다. 스스로 새롭게 알게 된 내용이거나 책의 설명이 너무 불친절한 경우 부가설명을 작성하거나 또는 새롭게 쓰고자 노력했습니다. 공부하다 생기는 의문들은 레포지토리 이슈에서 질의응답을 주고 받았으니 학습하다 궁금한 점이 생기면 검색 해보시기를 권장드립니다.
  3. 틀린 내용이 있다면 댓글로 알려주시면 감사하겠습니다.
  4. 코드나 책 내용 캡쳐 내용들은 다른 블로그의 캡쳐본이나 텍스트를 가져와 작성하였습니다. 대부분 출처를 표기하였으나 누락된 경우 원하시는 조치 내용을 댓글로 남겨주시면 시정하겠습니다.

개방 폐쇄 원칙(OCP)

개방 폐쇄 원칙은 코드의 어떤 부분은 변경을 통해 기능이 다양해지고 확장하려는 성질이 있고, 어떤 부분은 고정되어 있음을 일컫는 원칙이다. 각각 다른 목적과 다른 이유 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어 주는 것이 개방 폐쇄 원칙이다.

3장에서 다룰 템플릿은 변화가 일어나지 않는 부분을 변경이 자주 일어나는 부분으로부터 독립시키는 방법이다.

3.1 다시 보는 초난감 DAO

DB 커넥션처럼 제한적인 리소스를 공유해 사용하는 서버에서 동작하는 JDBC 코드는 예외처리가 반드시 필요하다. 예외가 발생하더라도 사용한 리소스를 반드시 반환하도록 만들어야 한다. 그렇지 않다면 어느 순간에는 커넥션 풀의 여유가 없어지고 리소스가 모자라며 서버가 터질 수 있다.

Code : UserDao의 deleteAll()

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

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

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

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

Code : 예외가 발생해도 리소스를 반환하는 deleteAll() 수정

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

        try {
            c = getConnection();
            ps = c.prepareStatement("delete from users");
            ps.executeUpdate();
                        // try 블록 안에 있는 코드는 예외가 발생할 수 있는 것들이다.
        } catch(SQLException e) {
            throw e;
        } finally { // 예외 발생유무와 관련없이 꼭 수행하는 블럭이다.
            if (ps != null) { try { ps.close(); } catch(SQLException e) {} }ㅛ
                        // close() 메서드에서도 예외가 발생할 수 있어 try를 한 겹 더 쌓은 모습이다.
                        // Java 문법 중 try 키워드 옆에 괄호열고 예외가 발생하면 자동적으로 close()를 수행하도록 할 
                        // 자원 할당 및 요청 코드들을 넣을 수 있다.
            if (c != null) { try { c.close(); } catch(SQLException e) {} }
                        // 변수가 null인지 검사하는 조건문은 NullPointerException을 방지하기 위함이다
        }
    }

어느정도 예외 발생에 리소스를 반환할 수 있도록 대처했다. 그러나...

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

코드가 너무 복잡하다. 예외처리 블록이 이중으로 중첩되고, 모든 자원 할당 메서드에 반복적으로 나타난다. 매 메서드마다 이러한 코드를 복붙할 수도 없는 노릇이다. 복사 붙여넣기 하다가 코드에 문제가 생길 여지는 항상 존재하기에 복붙은 일단 멀리 치우고 생각하자.

자주 반복되고 중복되는 코드를 분리해내야 한다. deleteAll() 메서드에서 변하지 않는 부분은

  1. 커넥션을 가져오고
  2. PreparedStatements를 실행하고
  3. 예외를 캐치하며
  4. 자원을 반환하는 close() 메서드 호출

이다. 이 변하지 않는 부분을 변하는 부분에서 분리하는 방법이 있을까?

템플릿 메서드 패턴의 적용

템플릿 메서드 패턴은 상속을 통해 기능을 확장한다. 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메서드로 두어 상속을 통해 오버라이드하여 사용한다.

메서드 추출을 통해 변하는 부분은 makeStatement()라는 메서드로 분리한다.

Code : makeStatement()를 구현한 UserDao 서브클래스

public class UserDao {
        abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
        //추상 메서드를 품고있는 클래스를 상속할 때 추상 메서드의 오버라이드는 필수다.

        public void deleteAll() throws SQLException {
    //...
            try {
                c = dataSource.getConnection();
                ps = makeStatement(c);
                ps.executeUpdate();
            } catch (SQLException e) {...}
        }
}

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

이제 PreparedStatement당 try-catch 이중블록 지옥은 벗어날 수 있다. 그럭저럭 개방 폐쇄 원칙을 지키는 구조는 만들어냈지만 제한이 많다. 매 로직마다 상속을 이용하는것은 투머치하다. 또한 구조가 이미 컴파일 타임에 관계가 다 결정되어 있어 유연성이 떨어진다.

전략 패턴(Strategy Pattern)

OCP를 지키면서 유연하고 확장성을 모두 가져갈 수 있다. 전략에 해당하는 변하는 부분과 컨텍스트에 해당하는 변하지 않는 부분을 둘로 나눈 다음, 변하는 부분은 전략의 인터페이스를 통해 외부의 전략 구체 클래스에 위임한다.

변하는 부분이 contextMethod()에 해당하며 DB를 업데이트하는 작업이라는 변하지 않는 'Context'를 갖는다. 전략에 해당하는 PreparedStatement를 만드는 부분이 전략에 해당하는데, 이 전략은 Connection을 필요로 해 Context에서 만들어둔 DB 커넥션을 전달해야 한다.

Code : StatementStrategy 인터페이스 정의

public inteface StatementStrategy{
        PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

Code : StatementStrategy 전략 클래스를 구현한 deleteAll()

public class DeleteAllStatement implements StatementStrategy {
    @Override
    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}

DeleteAllStatement는 StatementStrategy를 구현한 클래스이다. 하나의 전략이다.

Code: 전략패턴을 적용한 deleteAll() 메서드

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

        StatementStrategy strategy = new DeleteAllStatement();  
                // 전략이 고정되어있다.
        ps = starategy.makePreparedStatement(c);

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

Context 안에서 전략을 구현한 구체 클래스에 의존하는 것은 올바르지 않다.

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

전략 패턴은 Context가 사용할 전략을 Client가 결정하는 것이 일반적이다. Client가 구체적인 전략을 정해 Context에 넘겨주는 것이 일반적이다.

고정된 전략 선택을 다음과 같이 바꿔보자.

Code : 메서드로 분리한 컨텍스트 코드

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

Code : 클라이언트 책임을 담당할 deleteAll() 메서드

public void deleteAll() throws SQLException {
    StatementStrategy st = new DeleteAllStatement(); // 클라이언트에서 전략을 선택하여
    jdbcContextWithStatementStrategy(st);            // 컨텍스트에 넘겨준다.
}

클라이언트와 컨텍스트가 하나의 클래스 내에 존재하지만, 의존관계와 책임은 이상적이다.

3.3 JDBC 전략 패턴의 최적화

deleteAll() 메서드를 전략과 컨텍스트로 분리했다. jdbcContextWithStatementStrategy()는 DAO 메서드가 모두 공유할 수 있게 됐다. 각각의 DAO 메서드는 전략을 선택하는 클라이언트 책임을 지도록 작성한다.

전략과 클라이언트의 동거

여전히 문제가 있다. 매 DAO 메서드 마다 전략에 해당하는 클래스를 만들어야 한다는 점, add메서드와 같은 경우 추가적으로 사용할 객체가 있는 경우 생성자와 인스턴스 변수를 추가하여 클래스 만들기가 매우 번거롭다는 점이다.

로컬 클래스

간단하게 UserDao 내부에 inner class로 정의해버리는 방법이 있다. 이렇게 하면 전략마다 클래스 파일을 만들지 않아도 된다.

public void add(final User user) throws SQLException {
    // add 메서드 내부에 선언된 클래스. add 메서드 내부에서만 사용할 수 있다.
      class AddStatement implements StatementStrategy {  
          User user;

          public AddStatement(User user) {
              this.user = user; 
                        // add 메서드에 사용할 User 정보는 생성자로 받지만,
                        // 이 클래스가 선언된 곳의 user에도 직접 접근이 가능하다.
                        // 다만 외부의 변수는 지금과 같이 final 한정자를 붙여주어야 한다.
          }

          public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
              PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?,?,?)");
                ps.setString(1, user.getId());
                        //...

                        return ps;
          }


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

클래스 파일 갯수도 줄이고, add() 메서드 내에서 PreparedStatement 생성 로직을 볼 수 있으니 코드 가독성도 높아지고, 로컬 클래스가 선언된 스코프의 변수에도 접근이 가능하다. 로컬 클래스는 지역변수 취급을 받기 때문이다.

익명 내부 클래스

익명 클래스를 이용하면 클래스 이름도 제거할 수 있다.

public void add(final User user) throws SQLException {
        StatementStrategy st = 
        new StatementStrategy() { // 익명 클래스 선언이다. 추상 메서드는 bracket 블럭에서 오버라이드한다.
            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;
            }
        }
    );
}

3.4 컨텍스트와 DI

JDBC의 일반적인 작업 흐름을 담고있는 jdbcContextWithStatementStrategy를 다른 DAO에서도 사용할 수 있도록 바꾸어보자. UserDao 클래스 밖으로 끄집어 낸다.

클래스 분리

새로 JdbcContext라는 클래스를 만든다. JdbcContext에는 컨텍스트 메서드의 내용을 옮긴다. UserDao는 DataSource가 더 이상 필요하지 않은데, 이제 UserDao의 관심사는 쿼리를 작성하고 실행하는 것에만 두고, JdbcContext 클래스가 자원을 반환하고 커넥션을 가져오며 예외를 다루는 관심사를 가지게 될 것이다.

Code : JDBC 작업 흐름을 분리하여 만든 JdbcContext 클래스

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;

        //... 컨텍스트에서 다루게 될 예외처리 등
    }
}

Code : JdbcContext를 DI 받아서 사용하도록 만든 UserDao

public class UserDao {
    // ...
    private JdbcContext jdbcContext;

        // JdbcContext를 DI 받을 수 있다.
    public void setJdbcContext(JdbcContext jdbcContext) {
        this.jdbcContext = jdbcContext;             
    }

    public void add(final User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy( // 컨텍스트에서 전략 부분을 익명 클래스로 주입한다.    
            new StatementStrategy() {...}
        );
    }
}

JdbcContext는 인터페이스가 아닌 구체 클래스다. JdbcContext가 변화할 가능성이 있다면 인터페이스를 사이에 두고 의존 클래스를 바꾸겠지만, 굉장히 기본적이고 독립적인 JDBC 컨텍스트의 코드가 바뀔 가능성은 없다.

기존의 UserDao→DataSource 의존관계에 JdbcCotext 빈이 그 사이에 끼게 된다.

 

Code : 설계 변화를 반영한 빈 설정파일

        <bean id="userDao" class="springbook.user.dao.UserDao">
        <property name="jdbcContext" ref="jdbcContext" />
    </bean>
        ...
    <bean id="jdbcContext" class="springbook.user.dao.JdbcContext">
        <property name="dataSource" ref="dataSource" />
    </bean>

JdbcContext를 DI할 때 인터페이스를 중간에 끼지않고 적용했는데, 문제가 있지 않을까 걱정이 될 수도 있다. DI 개념을 충실히 따르면 클래스 레벨에서 의존관계가 고정되지 않도록 인터페이스를 두고, 런타임에 동적으로 주입하는 것이 맞지만, 스프링의 DI는 객체의 생성과 관계설정에 대한 권한을 컨테이너가 담당하는, IoC 개념을 포괄한다. 그렇기에 스프링의 DI를 잘 따르고 있다고 볼 수 있다. UserDao와 JdbcContext가 매우 긴밀하고 강하게 결합되어 있고, 강한 응집도를 가지고 있다. 만약 JDBC 대신 JPA나 ORM을 사용한다고 하면, 기존의 코드는 크게 의미가 없기 떄문에 모두 수정하여야 하기 때문에 강하게 결합이 되어도 상관이 없는 이유다.

JdbcContext를 DI 하는 이유는

  1. JdbcContext는 JDBC 컨텍스트 메서드를 제공하는 서비스 오브젝트로 의미가 있고, stateless하므로 스프링 컨테이너의 싱글톤 핀으로 공유되는 것이 좋다.
  2. JdbcContext 또한 DI로 다른 Bean에 의존하기 떄문이다. dataSource를 DI로 주입받기 위해서는 자기 자신도 스프링 빈이 되어야 한다.

수동으로 DI하기

스프링 빈으로 JdbcContext를 DI하는 대신, UserDao 내부에서 직접 DI를 적용하는 방법이 있다.

  1. 스프링 빈을 포기하면서 싱글톤도 포기하지만, DAO마다 하나씩 오브젝트를 생성하는 것으로 타협을 한다.
  2. JdbcContext를 스프링 빈에서 제외하게 되면, JdbcContext의 생성과 초기화를 누군가 책임져야 한다. UserDao가 제어권을 가지어 전통적인 방법으로 직접 객체를 생성한다.
  3. JdbcContext는 스프링 빈이었기 때문에 필요한 객체를 스프링 컨테이너가 주입해주었지만, 수동으로 직접 DI를 하게 된다면 JdbcContext는 스프링 빈이 아니기 때문에 DI 컨테이너로 DI받을 수 없다. UserDao가 JdbcContext의 DI까지 담당한다. DataSource는 JdbcContext에 주입하는 대신 UserDao가 주입받고 JdbcContext를 초기화하는 과정에 사용한다. 즉 UserDao가 DI 컨테이너 역할을 임시로 수행한다.

상황에 따라 스프링의 DI와 수동으로 DI하는 방법을 쓸 수 있지만, 나는 그냥 스프링의 DI를 쓸 것이다.

  1. 스프링 DI를 이용
    • 의존관계가 설정파일에 명확하게 드러난다.
    • 구체적인 클래스와의 관계가 직접 노출되는 문제가 있다.
  2. DAO코드를 이용한 수동 DI
    • 장점 : DAO 내부에서 DI가 이루어지기 때문에 관계와 전략을 외부에 감출 수 있다.
    • 단점 : 싱글톤으로 만들기가 번거롭고 여러 오브젝트에서 공유하기 어려움

3.5 템플릿과 콜백

지금까지 전략패턴을 익명 클래스를 통해 재사용할 부분과 변화가 잦은 부분을 분리하고, 익명 클래스를 통해 전략을 구성하는 방식을 템플릿/콜백 패턴이라고 부른다.

컨텍스트에 해당하는 템플릿은 흔히 사용하는 상용 프로그램에서 접할 수 있는, 어떤 목적을 위해 미리 만들어둔 틀을 의미한다. 고정된 작업 흐름을 가진 코드를 재사용한다는 의미에서 템플릿이란 이름을 가지게 되었다.

콜백은 다른 오브젝트의 메서드에 전달되지만, 파라미터와 다르게 자기 자신의 로직을 전달 받는 객체로 하여금 실행하도록 만든다. 다른 변수나 객체 파라미터와는 다르게 값을 전달하려는 의도와는 거리가 멀다. 템플릿의 작업 흐름에서 필요한 작업을 처리하기 위한, 단일 메서드를 가진 인터페이스를 구현한 익명 내부 클래스 구조가 일반적이며, 많은 메서드를 가질 필요가 없다(하지만 절대란 없는 법이기도 하다). 공장의 일부 공정을 담당하는 장비의 역할이다. 템플릿의 작업 흐름에서 발생하는 컨텍스트(여기서는 작업의 흐름에서 필요한 state에 가까운 의미이다)를 받는 파라미터를 가지고 있다.

일반적인 DI VS 템플릿/콜백

일반적인 DI에서는 템플릿에 두고두고 사용할 인스턴스 변수를 setter로 담아 인스턴스 변수에 가지고 있을 것이지만,

  1. 템플릿/콜백 방식에선 필요한 콜백 오브젝트를 매번 새롭게 전달받는다.
  2. 콜백 오브젝트는 자신을 생성한 클라이언트의 내부 클래스이기 떄문에, 클라이언트 내부의 필드에 직접 참조가 가능하다. (final 변수를 두어 콜백의 실행이 클라이언트 state를 변경하지 않도록 한다)

템플릿 콜백 패턴은 DI+전략패턴의 유니크한 패턴으로 이해하는 것이 좋다.

JS와 같은 언어들은 함수도 일급 객체로 취급하기 때문에 함수를 넘겨주는 것이 가능하지만, Java에서는 함수를 직접 넘기지는 못하고 클래스로 감싸서 전달하는 방법이 유일하다.

익명 클래스는 최선일까?

익명 클래스의 귀찮은 작성, 떨어지는 가독성, 다중 괄호 등의 문제는 불편함을 일으킬 수 있다. 기존 JDBC 코드를 수정할 때 반복되는 try/catch/finally 블럭과 prepareStatement를 분리하듯이 콜백도 분리하여 재활용할 수 있는 부분은 해보자는 것이다.

Code : 익명 내부 클래스를 사용한 클라이언트 코드를 분리하기

//------------------------------------------
//Before
//------------------------------------------
public class UserDao {
    //...
    public void deleteAll() throws SQLException {
            this.jdbcContext.workWithStatementStrategy(
                new StatementStrategy() {
              @Override
              public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                  return c.prepareStatement("delete from users");
              }
          }
             );
    }
}
/*
실제로 변하는 부분은 내부의 SQL 쿼리문이고, 나머지 메서드 시그니쳐, 콜백 익명 클래스의 정의 등은
반복되는 부분인데, 극히 일부에 불과한 쿼리문을 다수 작성하고자 불필요한 부분이 너무 많다.
*/
//------------------------------------------
//After
//------------------------------------------

public class UserDao {
    // ...
    public void deleteAll() throws SQLException {
        executeSql("DELETE FROM users WHERE 1=1");
                // SQL 쿼리에 대응하는 메서드를 만들때 마다 반복적으로 정의하고 작성했던 것들을 executeSql로 분리하였다.
    }
    // ...
    private void executeSql(final String query) throws SQLException {
                /*String query를 final로 선언해 익명 클래스가 접근할 수 있도록 함*/
        this.jdbcContext.workWithStatementStrategy(
            new StatementStrategy() {
                @Override
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                    PreparedStatement ps = c.prepareStatement(query);
                    return ps;
                }
            }
        );
    }
}

일반적으로 응집력이 높은 코드를 작성하기 위해서 성격이 다른 코드는 분리하는 편이 낫지만, 지금의 경우는 응집력이 강한 코드기 때문에 한 곳에 모여있는 것이 좋다. 구체적인 구현, 전략패턴, DI, 익명 내부 클래스 등은 숨기고 외부에선 메서드 호출로 이 모든 구현을 쉽게 누릴 수 있도록 노출한다.

정리하기

  1. 고정된 작업 흐름을 구현하면서 반복되는 코드가 있다면 먼저 메서드로 분리해보자.
  2. 일부 작업이 자주 바뀐다면, 인터페이스를 끼고 전략 패턴을 적용하자.
  3. DI로 의존관계를 관리하도록 해보자.
  4. 한 어플리케이션 내에서 바뀌는 부분이 여러 종류가 만들어 질 수 있다면 템플릿/콜백 패턴을 적용해보자.

0개의 댓글