본문 바로가기
Back-End/토비의 스프링

토비의 스프링 | 3장. 템플릿

by Havi 2017. 1. 27.

템플릿이란 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로 부터 독립시켜 효과적으로 활용할 수 있도록 하는 방법을 말한다. 3장에서는 템플릿 기법을 적용하는 방법에 대해 살펴본다.

3.1 다시보는 초난감 DAO

UserDao의 여러가지 면에서 개선작업을 했지만 아직 미흡한 점이 있다. 바로 예외처리이다.

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

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

    ps.close();
    c.close();
}
  • Connection과 PreparedStatement는 보통 풀(pool)방식으로 운영된다. 풀 안에 제한된 리소스(Connection, Statement)를 만들어 두고 이를 할당하고, 반환하는 방식으로 운영된다.
  • close()는 단순히 종료의 의미가 아니라 리소스 반환의 의미가 있다.
  • 만약, 위 코드에서 preparedStatement에서 에러가 발생하면 close()를 반환하지 않게 되면 미처 Connection을 반환하지 못하여 계속 쌓이게 되고 리소스 여유가 없어져 서버가 중단될 수 있다. typ/catch/finally를 통해 개선된 코드는 다음과 같다.
public void deleteAll() throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;

    try { //예외가 발생할 수 있는 코드를 try안에 선언한다.
        c = dataSource.getConnection();
        ps = c.prepareStatement("delete from users");
        ps.executeUpdate();
    } catch (SQLException e) { //예외가 발생하였을때 실행되는 부분
        throw e;
    } finally { //예외와는 상관없이 무조건 실행되는 부분
        if(ps != null) { //ps가 null이면 nullPointerException이 발생하므로 조건절 처리
            try {
                ps.close();
            } catch(SQLException e) {
                //close() 메소드 사용시 에러가 발생했을 경우 처리해 준다.
            }
        }
        if(c != null) {
            try {
                c.close();
            } catch(SQLException e) {

            }
        }
    }
}

3.2 JDBC try/catch/finally의 문제점

예외처리를 하였지만 중첩된 중괄호와 이런식의 반복되는 코드는 비효율적이고 많이 아쉽다. UserDao의 코드를 자세히 보면 변하는 부분과 변하지 않는 부분으로 나뉜다. 아래 코드의 주석에 변하는 부분을 제외하고는 모두 변하지 않는 코드이다.

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

    try {
        PreparedStatement ps = c.PreparedStatement("delete from users"); //변하는 부분!
        ps.executeUpdate();    
    } chatch(SQLException e) {
        ...
    }
}

템플릿 메소드 패턴의 적용

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

public abstract class UserDao {
    abstract protected PreparedStatement maekStatement(Connection c) throws SQLException;    
}

이름 서브클래스에서 구현하면 훨씬 깔끔하게 분리할 수 있다. 이제 기능을 확장하고 싶을 때 상속을 통해 자유롭게 확장할 수 있고, DAO 클래스에는 불필요한 변화가 생기지 않으므로 개방 패쇄 원칙(OCP)를 지킨샘이다.

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

전략 패턴의 적용

UserDaoDeleteAll()로 구조를 분리하였지만 확장시 매번 클래스를 상속받아야 하는 단점이 생기고 이에 따라 유연성이 떨어진다. 이를 해결하는 방법으로 오브젝트를 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 것이 전략 패턴이다. 전략 패턴은 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식이다.

  • 좌측에 있는 Context의 contextMethod()에서 일정한 구조를 가지고 동작하다가 특정 확장 기능은 Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임한다.
  • deleteAll() 메소드에서 변하지 않는 부분이 바로 contextMethod()에 해당한다.
  • 여기서 PreparedStatement를 만들어줄 외부 기능을 호출하는데 이 부분이 전략 패턴의 전략에 해당한다. PreparedStatement를 생성할 때는 DB 커넥션을 전달해야 한다.
public interface StatementStrategy {
    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

위의 인터페이스를 상속해서 실제 전략, 즉 바뀌는 부분을 생성하는 클래스는 다음과 같다.

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

PreparedStrategy 전략인 DeleteAllStatement를 contextMethod()에 해당하는 userDao의 deleteAll()메소드에 사용하면 어느정도 전략패턴이 적용된 것이다.

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

        StatementStrategy strategy = new DeleteAllStatement();
        ps = strategy.makePreparedStatement(c);

        ps.executeUpdate();
    } catch(SQLException e) {
        ...
    }
}
  • 하지만 전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서 전략을 바꿔 쓸 수 있다는 것인데, 전략 클래스인 DeleteAllStatement를 사용하도록 고정되어 있으면 전략 패턴에도 개방 패쇄 원칙(OCP)에도 맞지 않다.

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

context가 어떠한 전략을 사용할 것인가는 앞단의 client가 결정하는 것이 일반적이다. 이러한 방법은 1장에서 UserDao와 ConnectionMaker를 독립시키고 나서 UserDao의 구체적인 ConnectionMaker 구현 클래스를 문제가 있다고 판단했던 그 전략이다. 필요한 전략(ConnectionMaker)을 특정 구현 클래스(DConnectionMaker) 오브젝트를 클라이언트(UserDaoTest)가 만들어서 제공해 주는 방법이였다.

  • 이 구조에서 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 첵임을 분리한 것은 ObjectFactory이다.
  • 이를 일반화한 것이 DI이며 DI는 결국 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조이다.
//전략 파라미터를 받는 메소드로 분리한 컨텍스트 코드
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) {} }
    }
}

//클라이언트 책임을 담당할 deleteAll() 메소드 public void deleteAll() throws SQLException { StatementStrategy st = new DeleteAllStatement(); jdbcContextWithStatementStrategy(st); }

마이크로 DI: DI는 다양한 형태로 적용될 수 있으며 가장 중요한 개념은 제 3자의 도움을 통해 오브젝트 사이의 유연한 관계를 만드는 것이다. 다양한 방식의 DI 사용에서 DI는 매우 작은 단위의 코드와 메소드 사이에서 일어나기도 한다. 이렇게 DI의 장점을 단순화해서 IOC 컨테이너의 도움 없이 코드 내에서 적용한 경우를 마이크로 DI라고 한다 또는 코드에 의한 DI, 수동 DI라고도 한다.

3.3 JDBC 전략 패턴의 최적화

전략과 클라이언트의 동거

조금 더 개선할 두 가지 부분이 있다.

  • DAO 메소드마다 StatementStrategy 구현 클래스를 계속 만들어야 되서 클래스 파일의 개수가 늘어난다는 점
  • DAO 메소드에서 StatementStrategy에 전달할 User와 같은 부가적인 정보가 있는 경우 생성자나 이를 저장해둘 인스턴스 변수를 번거롭게 만들어야 한다는 점 해결방법은 다음과 같다.

로컬 클래스 StatementStrategy 전략 클래스를 매번 독립된 파일로 만들지 말고 UserDao 클래스 안에 내부 클래스로 정의하는 것이다.

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(); //로컬변수 직접접근으로 생성자 파라미터가 따로 필요가 없다.
    jdbcContextWithStatementStrategy(st);
}
  • AddStatement 클래스가 사용될 곳은 add() 메소드뿐이기에 메소드 안에 집어넣는 것이다. 이는 코드를 한번에 볼 수 있는 장점이 있으며 자바에서 지원하는 클래스 선언 방식중의 하나이다.
  • 내부 클래스의 장점으로 메소드 안의 로컬 변수에 접근 가능하다. 대신 외부 변수는 반드시 final로 선언해줘야 한다. > 중첩 클래스의 종류: 다른 클래스 내부에 정의되는 것을 중첩 클래스라 한다. 중첩 클래스는 독립적으로 만들어질 수 있는 스태틱 클래스, 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스(멤버 내부 클래스, 로컬 클래스, 익명 내부 클래스)로 구분된다.

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

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

//AddStatement -> 익명 내부 클래스
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;
        }
    };

만들어진 익명 내부 클래스는 오브젝트에서 딱 한번만 사용될 테니 변수에 따로 담지않고 파라미터에서 바로 생성하여 사용한다.

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()는 다른 DAO에서도 사용할 수 있도록 만들어보자. 분리해서 만들 클래스는 JdbcContext라고 한다면 이 클래스에는 DataSource에 대한 의존성이 필요하다. 따라서 DataSource 타입 빈을 DI 받을 수 있게 해줘야 한다.

...
public class JdbcContext {
    private DataSource dataSource;

    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) {
        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() {...}
        );
    }
}

JdbcContext의 특별한 DI

  • 스프링 DI는 기본적으로 인터페이스를 사이에 두어 의존 클래스를 바꿔 사용할 수 있도록 하는게 목적이다. 때문에 직접관계를 지어 하나만 사용하는게 아니라 다양한 설정에 대한 의존관계를 형성할 수 있는게 인터페이스를 중간에 두는 이유이다.
  • 하지만 JdbcContext는 인터페이스를 두지 않고 직접 UserDao와 관계를 맺는다. 다음은 의존관계를 보여주는 클래스 다이어그램이다.

  • 엄밀히 말하면 인터페이스를 사용하여 의존관계를 고정하지 않고 런타임 시에 다이나믹하게 주입해주는 것이 맞다.

  • 그러나 스프링의 DI는 넓은 의미에서 객체의 생성과 관계설정의 제어권한을 외부로 위임했다는 IOC개념을 포괄한다. 그런의미에서는 JdbcContext는 DI의 기본을 따른다고 볼 수 있다. 이러한 DI구조가 되어야 하는 이유는 다음과 같다.
    • JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이기 때문이다.
    • JdbcContext가 DI를 통해 다른 빈에 의존하고 있기 때문이다.
      • DI를 위해서는 주입하는 오브젝트나 주입받는 오브젝트나 모두 빈으로 등록되어야 한다.
  • 하지만 이러한 구조는 DI 적용에 있어서도 가장 마지막 단계에서 고려해볼 사항임을 잊지 말자.

코드를 이용하는 수동 DI

굳이 JdbcContext를 빈으로 만들지 않고 UserDao에서 직접 생성해서 사용하는 방법이다.

public class UserDao {
    ...
    private JdbcContext jdbcContext;

    public void setDataSource(DataSource dataSource) {
        this.jdbcContext = new jdbcContext();
        this.jdbcContext.setDataSource(dataSource); //의존 오브젝트 주입(DI)
        this.setDataSource = dataSource; //아직 JdbcContext를 적용하지 않은 메소드를 위해 저장
    }
}
  • 이 방법의 장점은 JdbcContext를 굳이 빈으로 분리하지 않고 내부에서 직접 만들어 다른 오브젝트에 대한 DI를 적용할 수 있다는 것이다.
  • 빈을 만들거나, 만들지 않거나, 인터페이스를 만들지 않는 것 등 모두 장단점이 있고 각각의 쓰임세와 이유에 따라 적절히 선택해서 사용할 줄 알아야 한다.


댓글0