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

2020-11-03

템플릿

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

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


다시 보는 초난감 DAO

예외처리 기능을 갖춘 DAO

일반적으로 서버에서는 제한된 개수의 DB 커넥션을 만들어서 재사용 가능한 풀로 관리한다. DB 풀은 매번 getConnection() 으로 가져간 커넥션을 명시적으로 close() 해서 돌려줘야지만 다시 풀에 넣었다가 다음 커넥션 요청이 있을 때 재사용할 수 있다. 그런데 오류가 날 때마다 미처 반환되지 못한 Connection이 계속 쌓이면 어느 순간에 커넥션 풀에 여유가 없어지고 리소스가 모자란다는 심각한 오류를 내며 서버가 중단될 수 있다.

JDBC 코드에서는 어떤 상황에서도 가져온 리소스를 반환하도록 try/catch/finally 구문을 사용해야 한다. 예외상황에서도 리소스를 제대로 반환할 수 있어야 한다. ``` public int getCount() throws SQLException { Connection c = null; PreparedStatement ps = null; ResultSet rs = null;

try { // 예외가 발생할 가능성이 있는 코드를 모두 try 블록으로 묶어준다. c = dataSource.getConnection();

ps = c.prepareStatement("select count(*) from users");

rs = ps.executeQuery();
rs.next();
return rs.getInt(1);   } catch (SQLException e) { // 예외가 발생했을 때 부가적인 작업을 해줄수 있도록 catch 블록을 둔다.
throw e;   } finally { // finally이므로 try 블록에서 예외가 발생했을 때나 안 했을 때나 모두 실행된다.
if (rs !=null) {
  try {
    rs.close();
  } catch (SQLException e) { // rs.close() 메소드에서도 SQLException이 발생할 수 있다.
  }
}
if (ps !=null ) {
  try {
    ps.close();
  } catch (SQLException e) { // ps.close() 메소드에서도 SQLException이 발생할 수 있다.
  }
}
if (c != null) {
  try {
    c.close();
  } catch (SQLException e) {
  }
}   } } ``` > close()는 만들어진 순서의 반대로 하는 것이 원칙이다.

변하는 것과 변하지 않는 것

JDBC try/catch/finally 코드의 문제점

복잡한 try/catch/finally 블록이 2중으로 중첩까지 되어 나오는데다, 모든 메소드마다 반복된다.
이 문제의 핵심은 변하지 않는, 그러나 많은 곳에서 중복되는 코드와 로직에 따라 자꾸 확장되고 자주 변하는 코드를 잘 분리해내는 작업이다.

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

템플릿 메소드 패턴의 적용

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

추출해서 별도의 메소드로 독립시킨 makeStatement() 메소드를 다음과 같이 추상메소드 선언으로 변경한다.

abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;

이를 상속하는 서브클래스를 만들어서 거기서 이 메소드를 구현한다. 고정된 JDBC try/catch/finally 블록을 가진 슈퍼클래스 메소드와 필요에 따라서 상속을 통해 구체적인 PreparedStatement를 바꿔서 사용할 수 있게 만드는 서브클래스로 깔끔하게 분리할 수 있다.

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

UserDao 클래스의 기능을 확장하고 싶을 때마다 상속을 통해 자유롭게 확장할 수 있고, 확장 때문에 기존의 상위DAO 클래스에 불필요한 변화는 생기지 않도록 할 수 있으니 객체지향 설계의 핵심 원리인 개방 폐쇄 원칙을 그럭저럭 지키는구조를 만들어낼 수는 있다.

하지만 템플릿 메소드 패턴으로의 접근은 제한이 많다. 가장 큰 문제는 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다는 점이다.

또 확장구조가 이미 클래스를 설계하는 시점에서 고정되어 버린다는 점이다. 변하지 않는 코드를 가진 UserDao의 JDBC try/catch/finally 블록과 변하는 PreparedStatement를 담고 있는 서브클래스들이 이미 클래스 레벨에서 컴파일 시점에 이미 그 관계가 결정되어 있다. 따라서 그 관계에 대한 유연성이 떨어져 버린다.

전략 패턴의 적용

개방 폐쇄 원칙을 잘 지키는 구조이면서도 템플릿 메소드 패턴보다 유연하고 확장성이 뛰어난 것이, 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다. 전략 패턴은 OCP 관점에 보면 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식이다.

deleteAll() 메소드에서 변하지 않는 부분이라고 명시한 것이 바로 컨텍스트가 된다. deleteAll()은 JDBC를 이용해 DB를 업데이트하는 작업이라는 변하지 않는 맥락(context)를 갖는다.

  • DB 커넥션 가져오기
  • PreparedStatement를 만들어줄 외부 기능 호출하기
  • 예외가 발생하면 이를 다시 메소드 밖으로 던지기
  • 모든 경우에 만들어진 PreparedStatement와 Connectio을 적절히 닫아주기

    두 번째 작업에서 사용하는 PreparedStatement를 만들어주는 외부 기응이 바로 전략 패턴에서 말하는 전략이라고 볼 수 있다.

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

public interface StatementStrategy {
  PreparedStatement makePreparedStatement(Connection c) throws Exception;
}

이 인터페이스를 상속한 실제 전략

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

확장된 PreparedStarategy 전략을 context에서 사용하면 된다.

public void deleteAll() throws SQLException {
  ...
  try {
    c = dataSource.getConnection();
    
    StatementStrategy strategy = new DeleteAllStatement();
    ps = strategy.makePreparedStatement(c);
    
    ps.executeUpdate();
  } catch (SQLException e) {
    ...
  }
}

하지만 전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서(OCP의 폐쇄 원칙) 전략은 바꿔 쓸 수 있다(OCP의 개방 원칙)는 것인데, 이렇게 컨텍스트 안에서 이미 구체적인 전략 클래스인 DeleteAllStatement를 사용하도록 고정되어 있다면 뭔가 이상하다. 컨텍스트가 인터페이스뿐아니라 특정 구현 클래스인 DeleteAllStatement를 직접 알고 있다는건, 전략 패턴에도 OCP에도 잘 들어맞는다고 볼 수 없다.

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

Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에 전달해야 한다.Context는 전달받은 그 Strategy 구현 클래스의 오브젝트를 사용한다.

  • 메소드로 분리한 try/catch/finally 컨텍스트 코드
    public void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException { // 클라이언트가 컨텍스트를 호출할 때 넘겨줄 전략 파라미터
    Connection c = null;
    PreparedStatement ps = null
      
    try {
      c = dataSource.getConnction();
        
      ps = stme.makePreparedStatement(c);
        
      ps.executeUpdate();
    } catch (SQLExcepton 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); // 컨텍스트 호출, 전략 오브젝트 전달
    }
    

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.preparedStatement(insert into users(id, name, password) values(?,?,?)");
      ps.setString(1, user.getId());
      ps.setString(2, user.getName());
      ps.setString(3, user.getPassword());
      
      return pa;
    }
  }
  
  StatementStrategy st = new AddStatement();
  jdbcContextWithStatementStrategy(st);
}

AddStatement 클래스를 로컬 클래스로서 add() 메소드 안에 집어넣었다. 마치 로컬 변수를 선언하듯이 선언하면 된다. 로컬 클래스는 선언된 메소드 내에서만 사용할 수 있다. AddStatement가 사용될 곳이 add() 메소드 뿐이라면, 이렇게 사용하기 전에 바로 정의해서 쓰는 것이다. 덕분에 클래스 파일이 하나 줄었고, add() 메소드 안에서 PreparedStatement 생성 로직을 함께 볼 수 있으니 코드를 이해하기도 좋다.

로컬 클래스의 다른 장점은 클래스가 내부 클래스이기 때문에 자신이 선언된 곳의 정보에 접근할 수 있다는 것이다. AddStatement는 User 정보를 필요로 한다. 내부 메소드는 자신이 정의된 메소드의 로컬 변수에 직접 접근할 수 있기 때문에 번거롭게 생성자를 통해 User 오브젝트를 전달해줄 필요가 없다. 다만, 내부 클래스에서 외부의 변수를 사용할 때는 외부 변수는 반드시 final로 선언해줘야 한다. user파라미터는 메소드 내부에서 변경될 일이 없으므로 final로 선언해도 무방하다.

익명 내부 클래스

AddStatement 클래스는 add() 메소드에서만 사용할 용도로 만들어졌으니 좀 더 간결하게 이름도 제거할 수 있다. 익명 내부 클래스는 선언과 동시에 오브젝트를 생성한다. 이름이 없기 때문에 클래스 자신의 타입을 가질 수 없고, 구현한 인터페이스 타입의 변수에만 저장할 수 있다.

만들어진 익명 내부 클래스의 오브젝트는 딱 한 번만 사용할 테니 굳이 변수에 담아 두지 말고 jdbcContextWithStatementStrategy() 메소드의 파라미터에서 바로 생성하는 편이 낫다.

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;
      }
    }
  );
}
  • 중첩 클래스의 종류
    • 다른 클래스 내부에 정의되는 클래스를 중첩 클래스라고 한다. 중첩 클래스는 독립적으로 오브젝트로 만들어질 수 있는 스태틱 클래스와 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스로 구분된다.
      • 내부 클래스는 다시 범위에 따라 세 가지로 구분된다. 멤버 필드처럼 오브젝트 레벨에 정의되는 멤버 내부 클래스와 메소드 레벨에 정의되는 로컬 클래스, 그리고 이름을 갖지 않는 익명 내부 클래스다. 익명 내부클래스의 범위는 선언된 위치에 따라서 다르다.
  • 익명 내부 클래스
    • 익명 내부클래스는 이름을 갖지 않는 클래스다. 클래스 선언과 오브젝트 생성이 결합된 형태로 만들어지며, 상속할 클래스나 구현할 인터페이스를 생성자 대신 사용해서 다음과 같은 형태로 만들어 사용한다. 클래스를 재사용할 필요가 없고, 구현한 인터페이스 타입으로만 사용할 경우에 유용하다.
      • new 인터페이스 이름() { 클래스 본문 };

컨텍스트와 DI

JdbcContext의 분리

전략 패턴의 구조로 보면 UserDao의 메소드가 클라이언트이고, 익명 내부 클래스로 만들어지는 것이 개별적인 전략이고, jdbcContextWithStatementStrategy() 메소드는 컨텍스트다. 컨텍스트 메소드는 UserDao 내의 PreparedStatement를 실행하는 기능을 가진 메소드에서 공유할 수 있다. 그런데 JDBC의 일반적인 작업 흐름을 담고 있는 jdbcContextWithStatementStrategy()는 다른 DAO에서도 사용 가능하다. 그러니 jdbcContextWithStatementStrategy()를 UserDao 클래스 밖으로 독립시켜서 모든 DAO가 사용할 수 있게 해야 한다.

클래스 분리

분리해서 만들 클래스의 이름은 JdbcContext라고 하자. JdbcContext에 UserDao에 있던 컨텍스트 메소드를 workWithStatementStrategy()라는 이름으로 옮겨 놓는다. 그런데, 이렇게 하면 DataSource가 필요한 것은 UserDao가 아니라 JdbcContext가 돼버린다. DB 커넥션을 필요로 하는 코드는 JdbcContext 안에 있기 때문이다. 따라서 JdbcContext가 DataSource에 의존하고 있으므로 DataSource 타입 빈을 DI 받을 수 있게 해줘야 한다.

pulbic 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;
    
    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) { // JdbcContext를 DI 받도록 만든다.
      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에 의존하고 있다. 그런데 JdbcContext는 인터페이스인 DataSource와는 달리 구체 클래스다. 스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스를 바꿔서 사용하도록 하는 게 목적이다. 하지만 이 경우 JdbcContext는 그 자체로 독립적인 JDBC 컨텍스트를 제공해주는 서비스 오브젝트로서 의미가 있을 뿐이고 구현 방법이 바뀔 가능성은 없다. 따라서 인터페이스를 구현하도록 만들지 않았고, UserDao와 JdbcContext는 인터페이스를 사이에 두지 않고 DI를 적용하는 특별한 구조가 된다.

JdbcContext의 특별한 DI

UserDao와 JdbcContext 사이에는 인터페이스를 사용하지 않고 DI를 적용했다. 인터페이스를 거치지 않고 코드에서 바로 JdbcContext 클래스를 사용하고 있다. 비록 런타임 시에 DI 방식으로 외부에서 오브젝트를 주입해주는 방식을 사용하기 했지만, 의존 오브젝트의 구현 클래스를 변경할 수는 없다.

스프링 빈으로 DI

의존관계 주입(DI)이라는 개념을 충실히 따르자면, 인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않게 하고, 런타임 시에 의존할 오브젝트와의 관계를 다이내믹하게 주입해주는 것이 맞다. 따라서 인터페이스를 사용하지 않았다면 엄밀히 말해서 온전한 DI라고 볼 수는 없다. 그러나 스프링의 DI는 넓게 보자면 객체의 생성과 관계설정에 대한 제어권한을 오브젝트에서 제거하고 외부로 위임했다는 IoC라는 개념을 포괄한다. 그런 의미에서 JdbcContext를 스프링을 이용해 UserDao 객체에서 사용하게 주입했다는 건 DI의 기본을 따르고 있다고 볼 수 있다.

JdbcContext를 UserDao와 DI 구조로 만들어야 하는 이유

  • JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되기 때문이다.
  • JdbcContext가 DI를 통해 다른 빈에 의존하고 있기 때문이다.
코드를 이용하는 수동 DI
public class UserDao {
  ...
  private JdbcContext jdbcContext;
  
  public void setDataSource(DataSource dataSource) { // 수정자 메소드이면서 JdbcContext에 대한 생성, DI작업을 동시에 수행
    this.jdbcContext = new JdbcContext(); // JdbcContext 생성(IoC)
    
    this.jdbcContext.setDataSource(dataSource); // 의존 오브젝트 주입(DI)
    
    this.dataSource = dataSource; // 아직 JdbcContext를 적용하지 않은 메소드를 위해 저장해준다.
  }
}

JdbcContext를 어색하게 따로 빈으로 분리하지 않고 내부에서 직접 만들어 사용하면서도 다른 오브젝트에 대한 DI를 적용할 수 있다는 점이다. 이렇게 한 오브젝트의 수정자 메소드에서 다른 오브젝트를 초기화하고 코드를 이용해 DI 하는 것은 스프링에서도 종종 사용되는 기법이다.

  • 인터페이스를 사용하지 않는 클래스와의 의존관계이지만 스프링의 DI를 이용하기 위해 빈으로 등록해서 사용하는 방법은 오브젝트 사이의 실제 의존관계가 설정 파일에 명확하게 드러난다는 장점이 있다. 하지만 DI의 근본적인 원칙에 부합하지 않는 구체적인 클래스와의 관계가 설정에 직접 노출된다는 단점이 있다.
  • DAO의 코드를 이용해 수동으로 DI를 하는 방법은 JdbcContext가 UserDao의 내부에서 만들어지고 사용되면서 그 관계를 외부에는 드러내지 않는다는 장점이 있다. 필요에 따라 내부에서 은밀히 DI를 수행하고 그 전략을 외부에는 감출 수 있다. 하지만 JdbcContext를 여러 오브젝트가 사용하더라도 싱글톤으로 만들 수 없고, DI 작업을 위한 부가적인 코드가 필요하다는 단점도 있다.

템플릿과 콜백

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

템플릿/콜백의 동작원리

템플릿은 고정된 작업 흐름을 가진 코드를 재사용한다는 의미에서 붙인 이름이다. 콜백은 템플릿 안에서 호출되는 것을 목적으로 만들어진 오브젝트를 말한다.

템플릿/콜백의 특징

여러 개의 메소드를 가진 일반적인 인터페이스를 사용할 수 있는 전략 패턴의 전략과 달리 템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용한다. 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문이다. 하나의 템플릿에서 여러 가지 종류의 전략을 사용해야 한다면 하나 이상의 콜백 오브젝트를 사용할 수도 있다. 콜백은 일반적으로 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스로 만들어진다고 보면 된다.

콜백 인터페이스의 메소드에는 보통 파라미터가 있다. 이 파라미터는 템플릿의 작업 흐름 중에 만들어지는 컨텍스트 정보를 전달받을 때 사용된다.

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

템플릿/콜백 방식에서는 매번 메소드 단위로 사용할 오브젝트를 새롭게 전달받는다는 것이 특징이다.(템플릿에 인스턴스 변수를 만들어두고 사용할 의존 오브젝트를 수정자 메소드로 받아서 사용하는 것과의 차이)

편리한 콜백의 재활용

클라이언트인 DAO의 메소드는 간결해지고 최소한의 데이터 액세스 로직만 갖고 있게 된다. 하지만 익명 내부 클래스를 사용하기 때문에 상대적으로 코드를 작성하고 읽기가 조금 불편하다.

콜백의 분리와 재활용

변하지 않는 부분을 빼내서 메소드로 만들면 바뀌는 부분인 SQL 문장만 파라미터로 받아서 사용하게 할 수 있다. SQL을 담은 파라미터를 final로 선언해서 익명 내부 클래스인 콜백 안에서 직접 사용할 수 있게 하는 것만 주의하면 된다. 복잡한 익명 내부 클래스인 콜백을 직접 만들 필요조차 없어진다.

public void deleteAll() throws SQLException {
  executeSql("delete from users"); // 변하는 SQL 문장
}
private void executeSql(final String query) throws SQLException {
  this.jdbcContext.workWithStatementStrategy(
    new StatementStrategy() { // 변하지 않는 콜백 클래스 정의와 오브젝트 생성
      public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        return c.prepareStatement(query);
      }
    }
  );
}
콜백과 템플릿의 결합

executeSql() 메소드는 UserDao만 사용하기는 아깝다. 이렇게 재사용 가능한 콜백을 담고 있는 메소드라면 DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨도 된다.

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

executeSql() 메소드가 JdbcContext로 이동했으니 UserDao의 메소드에서도 jdbcContext를 통해 executeSql() 메소드를 호출하도록 수정한다.

public void deleteAll() throws SQLException {
  this.jdbcContext.executeSql("delete from users");
}

일반적으로는 성격이 다른 코드들은 가능한 한 분리하는 편이 낫지만, 이 경우는 반대다. 하나의 목적을 위해 서로 긴밀하게 연관되어 동작하는 응집력이 강한 코드들이기 때문에 한 군데 모여 있는 게 유리하다. 구체적인 구현과 전략 패턴, 코드에 의한 DI, 익명 내부 클래스 등의 기술은 최대한 감춰두고, 외부에는 꼭 필요한 기능을 제공하는 단순한 메소드만 노출해주는 것이다.

제네릭스를 이용한 콜백 인터페이스

자바 5에서 추가된 언어적인 특징을 잘 활용하면 좀 더 강력한 템플릿/콜백 구조를 만들 수 있다. 타입 파라미터라는 개념을 도입한 제네릭스를 이용하면 된다. 제네릭스를 이용하면 다양한 오브젝트 타입을 지원하는 인터페이스나 메소드를 정의할 수 있다.


스프링의 JdbcTemplate

스프링은 JDBC를 이용하는 DAO에서 사용할 수 있도록 준비된 다양한 템플릿과 콜백을 제공한다. 거의 모든 종류의 JDBC코드에 사용 가능한 템플릿과 콜백을 제공할 뿐만 아니라, 자주 사용되는 패턴을 가진 콜백은 다시 템플릿에 결합시켜서 간단한 메소드 호출만으로 사용이 가능하도록 만들어져 있기 때문에 템플릿/콜백 방식의 기술을 사용하고 있는지 모르고도 쓸 수 있을 정도로 편리하다.

스프링이 제공하는 JDBC 코드용 기본 템플릿은 JdbcTemplate이다.

  • JdbcTemplate의 초기화를 위한 코드
    public class UserDao {
    ...
    private JdbcTemplate jdbcTemplate;
      
    public void setDataSource(DataSource dataSource) {
      this.jdbcTemplate = new JdbcTemplate(dataSource);
        
      this.dataSource = dataSource;
    }
    }
    
  • JdbcTemplate을 적용한 deleteAll() 메소드
    public void deleteAll() {
    this.jdbcTemplate.update(
      new PreparedStatementCreator() {
        public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
          return con.prepareStatement("delete from users");
        }
      }
    );
    }
    

    SQL 문장만 전달하면 미리 준비된 콜백을 만들어서 템플릿을 호출하는 것까지 한 번에 해주는 편리한 메소드였다. JdbcTemplate에도 기능이 비슷한 메소드가 존재한다. 콜백을 받는 update() 메소드와 이름은 동일한데 파라미터로 SQL 문장을 전달한다는 것만 다르다.

    public void deleteAll() {
    this.jdbcTemplate.update("delete from users");
    }
    

    add() 메소드에 대한 편리한 메소드도 제공된다.

    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());
    

    PreparedStatement를 만들 때 사용하는 SQL은 동일하며 바인딩할 파라미터는 순서대로 넣어주면 된다.

queryForInt()

  • PreparedStatementCreator 콜백은 템플릿으로부터 Connection을 받고 PreparedStatement를 돌려준다.
  • ResultSetExtractor는 템플릿으로부터 ResultSet을 받고 거기서 추출한 결과를 돌려준다.
    public int getCount() {
    return this.jdbcTemplate.query(new PreparedStatementCreator() { // 첫 번째 콜백. Statement 생성
      public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
        resturn con.prepareStatement("select count(*) from users");
      }
    }, new ResultSetEctractor<Integer> () { // 두 번째 콜백, ResultSet으로부터 값 추출
      public Integer extractData(ResultSet rs) throws SQLException, DataAccessException {
        rs.next();
        return rs.getInt(1);
      }
    });
    }
    

    ResultSetExtractor는 ResultSet에서 추출할 수 있는 값의 타입은 다양하기 때문에 타입 파라미터를 갖는다.

JdbcTemplate은 Integer 타입의 결과를 가져올 수 있는 SQL 문장만 전달해주면 이러한 기능을 해주는 queryForInt()라는 메소드를 제공한다. 위의 복잡한 코드를 단 한줄로 바꿀 수 있다.

public int getCount() {
  return this.jdbcTemplate.queryForInt("select count(*) from users");
}

queryForObject()

get() 메소드에 JdbcTemplate을 적용.

  • ResultSet에서 복잡한 User 오브젝트로 만드는 작업이다.

이를 위해, getCount()에 적용했던 ResultSetExtractor 콜백 대신 RowMapper 콜백을 사용하겠다. ResultSetExtractor와 RowMapper 모두 템플릿으로부터 ResultSet을 전달받고, 필요한 정보를 추출해서 리턴하는 방식으로 작동한다. 다른 점은 ResultSetExtractor는 ResultSet을 한 번 전달받아 알아서 추출 작업을 모두 진행하고 최종 결과만 리턴해주면 되는 데 반해, RowMapper는 ResultSet의 로우 하나를 매핑하기 위해 사용되기 때문에 여러 번 호출될 수 있다는 점이다.

public User get(String id) {
  return this.jdbcTemplate.queryForObject("select * from users where id = ?",
          new Object[] {id}, // SQL에 바인딩할 파라미터 값, 가변인자 대신 배열을 사용한다.
          new RowMapper<User>() { // ResultSet 한 로우의 결과를 오브젝트에 매핑해주는 RowMapper 콜백
              public User mapRow(ResultSet rs, int rowNum) throws SQLException {
                  User user = new User();
                  user.setId(rs.getString("id));
                  user.setName(rs.getString("name));
                  user.setPassword(rs.getString("password"));
                  return user;
              }
          });
}

첫 번째 파라미터는 PreparedStatement를 만들기 위한 SQL이고, 두 번째는 여기에 바인딩할 값들이다. update()에서처럼 가변인자를 사용하면 좋겠지만 뒤에 다른 파라미터가 있기 때문에 이 경우엔 가변인자 대신 Object 타입 배열을 사용해야 한다. 배열 초기화 블록을 사용해서 SQL의 ?에 바인딩할 id 값을 전달한다. queryForObject() 내부에서 이 두 가지 파라미터를 사용하는 PreparedStatement 콜백이 만들어질 것이다.

queryForObject()는 SQL을 실행하면 한 개의 로우만 얻을 것이라고 기대한다. 그리고 ResultSet의 next()를 실행해서 첫 번째 로우로 이동시킨 후에 RowMapper 콜백을 호출한다. 이미 RowMapper가 호추로디는 시점에서 ResultSet은 첫 번째 로우를 가리키고 있으므로 다시 rs.next()를 호출할 필요는 없다. RowMapper에서는 현재 ResultSet이 가리키고 있는 로우의 내용을 User 오브젝트에 그대로 담아서 리턴해주기만 하면 된다.

queryForObject()를 SQL을 실행해서 받은 로우의 개수가 하나가 아니라면 예외를 던지도록 만들어져 있다. 이 때 던져지는 예외가 바로 EmptyResultDataAccessException이기 때문에 별다른 예외처리를 추가하지 않아도 된다.

query()

RowMapper를 좀 더 사용하여 모든 사용자 정보를 가져오는 getAll() 메소드를 추가한다. 이는 List 타입으로 가져오면 된다.

queryForObject()는 쿼리의 결과가 로우 하나일 때 사용하고, query()는 여러 개의 로우가 결과로 나오는 일반적은 경우에 쓸 수 있다. query()의 리턴 타입은 List다.

public List<User> getAll() {
  return this.jdbcTemplate.query("select * from users order by id",
          new RowMapper<User>() { // ResultSet 한 로우의 결과를 오브젝트에 매핑해주는 RowMapper 콜백
              public User mapRow(ResultSet rs, int rowNum) throws SQLException {
                  User user = new User();
                  user.setId(rs.getString("id));
                  user.setName(rs.getString("name));
                  user.setPassword(rs.getString("password"));
                  return user;
              }
          });
}

첫 번째 파라미터에는 실행할 SQL쿼리를 넣는다. 바인딩할 파라미터가 있다면 두 번째 파라미터에 추가할 수도 있다. 파라미터가 없다면 생략할 수 있다. 마지막 파라미터는 RowMapper 콜백이다. query() 템플릿은 SQL을 실행해서 얻은 ResultSet의 모든 로우를 열람하면서 로우마다 RowMapper 콜백을 호출한다. SQL 쿼리를 실행해 DB에서 가져오는 로우의 개수만큼 호출될 것이다. RowMapper는 현재 로우의 내용을 User 타입 오브젝트에 매핑해서 돌려준다. 이렇게 만들어진 User 오브젝트는 템플릿이 미리 준비한 List 컬렉션에 추가된다. 모든 로우에 대한 작업을 마치면 모든 로우에 대한 User 오브젝트를 담고 있는 List 오브젝트가 리턴된다.

테스트 보완

네거티브 테스트에 신경쓰자. get()이라면 Id가 없을 때는 어떻게 되는지, getAll()이라면 결과가 하나도 없는 경우에는 어떻게 되는지를 검증해야한다.

query()는 결과가 없을경우에 queryForObject()처럼 예외를 던지지는 않는다. 대신 크기가 0인 List 오브젝트를 돌려준다.

재사용 가능한 콜백의 분리

중복 제거
  • 재사용 가능하도록 독립시킨 RowMapper
    public class UserDao {
    private RowMapper<User> userMapper = new RowMapper<User>() {
      public User mapRow(ResultSet rs, int rowNum) throws SQLException {
        User user = new User();
        user.setId(rs.getString("id"));
        user.setName(rs.getString("name"));
        user.setPassword(rs.getString("password"));
        return user;
      }
    };
    }
    

    인스턴스 변수에 저장해둔 userMapper 콜백 오브젝트는 get()과 getAll()에서 사용하면 된다.

    public User get(String id) {
    return this.jdbcTemplate.queryForObject("select * from users where id = ?", new Object[] {id}, this.userMapper);
    }
    public List<User> getAll() {
    return this.jdbcTemplate.query("select * from users order by id", this.userMapper);
    }
    

    UserDao에는 User 정보를 DB에 넣거나 가져오거나 조작하는 방법에 대한 핵심적인 로직만 담겨있다. User라는 자바오브젝트와 USER 테이블 사이에 어떻게 정보를 주고받을지, DB와 커뮤니케이션하기 위한 SQL 문장이 어떤 것인지에 대한 최적화된 코드를 갖고 있다. 만약 사용할 테이블과 필드 정보가 바뀌면 UserDao의 거의 모든 코드가 바뀐다. 따라서 응집도가 높다고 볼 수 있다.

반면에 JDBC API를 사용하는 방식, 예외처리, 리소스의 반납, DB 연결을 어떻게 가져올지에 관한 책임과 관심은 모두 JdbcTemplate에게 있다. 따라서 변경이 일어난다고 해도 UserDao 코드에는 아무런 영향을 주지 않는다. 그런 면에서 책임이 다른 코드와는 낮은 결합도를 유지하고 있다.

더 개선할 점
  • userMapper가 인스턴스 변수로 설정되어 있고, 한 번 만들어지면 변경되지 않는 프로퍼티와 같은 성격을 띠고 있으니 아예 UserDao 빈의 DI용 프로퍼티로 만들자. 이렇게 userMapper를 분리할 수 있다면 User의 프로퍼티와 User 테이블의 필드 이름이 바뀌거나 매핑 방식이 바뀌는 경우에 UserDao 코드를 수정하지 않고도 매핑정보를 변경할 수 있다.
  • DAO 메소드에서 사용하는 SQL 문장을 UserDao 코드가 아니라 외부 리소스에 담고 이를 읽어와 사용하게 하자. 이렇게 해두면 DB 테이블의 이름이나 필드 이름을 변경하거나 SQL쿼리를 최적화 할 때도 UserDao 코드에는 손을 댈 필요가 없다.

정리

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

출처 : https://github.com/Masssidev/toby-vol1