스프링 3.1 - 전략 패턴과 템플릿

김법우·2023년 2월 20일
0

Spring

목록 보기
1/4

변하는 것과 변하지 않는 것, 그리고 분리와 재사용

중복되는 try-catch-finally 구문

OCP 원칙
OCP 원칙은 확장에는 자유롭게 열려있고 변경에는 닫혀있다는 객체지향 설계 핵심 원칙이다.

특정 코드에는 변경을 통해 기능을 확장하고 다양하게 만들려는 성질이 있고 특정 코드는 고정되어 변하지 않을려는 성질을 가진다. 또한 변경을 통해 기능을 다양하게 하려는 와중에도 그 원인과 목적이 다른 경우가 있다.

“변화의 특성이 다른 곳을 구분하고 각각 다른 원인과 목적, 시점에 독립적으로 변경 될 수 있도록 효율적인 구조를 만드는 것이 핵심”

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

    try {
        // DB Connection 가져오기
        c = this.dataSource.getConnection();

        // SQL 쿼리(statement)를 생성
        ps = c.prepareStatement("delete from users");

        // statement 를 실행
        ps.executeUpdate();
    } catch (SQLException e) {
        throw e;
    } finally {
        if (ps != null) {
            try {
                ps.close();
            } catch (SQLException e) {
                throw e;
            }
        }

        if (c != null) {
            try {
                c.close();
            } catch (SQLException e) {
                throw e;
            }
        }
    }
}
public void add(User user) throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;

    try {
        // DB Connection 가져오기
        c = this.dataSource.getConnection();

        // SQL 쿼리(statement)를 생성
        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());

        // statement 를 실행
        ps.executeUpdate();
    } catch (SQLException e) {
        throw e;
    } finally {
        if (ps != null) {
            try {
                ps.close();
            } catch (SQLException e) {
                throw e;
            }
        }

        if (c != null) {
            try {
                c.close();
            } catch (SQLException e) {
                throw e;
            }
        }
    }
}

공유 리소스인 Connection, PreparedStatment 를 DB Pool 에서 받아와 하기에 반복되는 try-catch-finally 구문이 포함되어 있다. delete, findOne 등등 UserDao 의 모든 DB 엑세스 메서드는 위의 구조가 반복된다.

✏️ 리소스 반환과 close
Conneciton, PreparedStatement, ResultSet 등의 리소스는 Pool 방식으로 운영된다. 제한된 수의 미리 생성된 리소스를 필요시 할당하고 사용 종료시 반환하여 Pool 에 넣어야한다.
리소스 사용이 끝나는 즉시 반환하지 않으면 리소스 고갈 문제가 발생 할 수 있으므로 finally 구문을 통해 에러 발생 유무와 관계없이 무조건 반환하도록 처리한다.


OCP 를 지키지 않은 코드의 문제점과 그 원인

문제점

  • 수백개의 DAO 로직에서 Connection, PreparedStatement, ResultSet 과 같은 공유 자원을 순차적으로 할당하는 로직이 반복된다.
  • 공유 자원을 DAO 메서드에서 직접적으로 할당함으로써 복잡한 try-catch-finally 구문이 반복된다.
  • 각 DAO 로직이 올바르게 리소스를 반환하는지에 대한 테스트 또한 반복된다. ⇒ 시스템의 유지보수 비용을 극대화 시키고, 안정성을 낮춘다.

원인

  • DB 작업을 수행하는 메서드에서는 변하지 않는 코드가 계속해서 확장되야하고 변경되어야하는 각 DAO 로직과 결합되어있기 때문. ⇒ 변경되는 이유가 다르면서 변경될 가능성이 적고, 중복되는 로직을 분리하자!

해결 : 템플릿 메서드 패턴

✏️ 템플릿 메서드 패턴
상속을 통해 기능을 확장할 수 있도록 하는 디자인 패턴. 변하지 않는 부분을 슈퍼 클래스에 두고 확장, 변하는 부분을 서브 클래스에 둔다.

변하는 부분은 preparedStatement 를 만들어내는 즉, SQL 쿼리를 생성하는 부분이고 변하지 않는 부분은 공유 자원 사용에 의해 필요한 try-catch-finally 구문이다.

따라서 Connection 을 받아와 preparedStatement 를 생성하는 부분을 서브 클래스에게 위임하고 try-catch-finally 구문은 슈퍼 클래스에서 처리한다.

// UserDaoDeleteAll.java
public class UserDaoDeleteAll extends UserDao {
    @Override
    PreparedStatement makeStatement(Connection c) throws SQLException {
        // SQL 쿼리(statement)를 생성
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}
// UserDao.java
abstract PreparedStatement makeStatement(Connection c) throws SQLException;

...

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

    try {
        // DB Connection 가져오기
        c = this.dataSource.getConnection();

        // SQL 쿼리(statement)를 생성
        **ps = this.makeStatement(c);**

        // statement 를 실행
        ps.executeUpdate();
    } catch (SQLException e) {
        throw e;
    } finally {
        if (ps != null) {
            try {
                ps.close();
            } catch (SQLException e) {
                throw e;
            }
        }

        if (c != null) {
            try {
                c.close();
            } catch (SQLException e) {
                throw e;
            }
        }
    }
}

PreparedStatement 를 생성하는 부분을 서브 클래스에서 수행하고, 슈퍼 클래스에서는 try-catch-finally 구문내에서 해당 SQL 쿼리를 실행한다.

문제점과 원인

  • 확장 구조가 클래스를 설계하는 시점에서 고정된다
    • 변하지 않는 코드를 가지는 클래스와 변하는 코드를 가진 클래스가 설계 시점부터 상속에 의해 결정된다.
    • 컴파일 시점에 결정된 객체 관계는 유연성을 떨어뜨린다.
  • 기존 DAO 의 개별 메서드였던 기능들이 상속을 통해 생성되는 클래스로 대체되어 매번 상속, 클래스 생성을 반복해야한다.

해결 : 전략 패턴 (1) - Conetext, Strategy

동일하게 OCP 원칙을 지키면서 템플릿 메서드 패턴보다 유연하고 확장성이 뛰어난 해결책을 제시한다.

전략 패턴
확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식.

변하지 않는 요구사항, 기능 맥락이 Context 의 contextMethod 가 된다. contextMetod 는 자신의 기능 맥락 안에서 확장가능하고 변경가능한 부분을 인터페이스로 추출한 Strategy 에 의존하고 이를 구현하는 구체 클래스에서 실질적인 확장과 변경을 책임진다.

UserDao 의 deleteAll 의 실질적인 일련의 과정은 아래와 같다.

  • DB 커넥션 가져오기
  • 외부 기능 호출을 통해 PreparedStatement 생성
  • 전달받은 PS 실행
  • 에러 발생시 메서드 밖으로 던지기
  • 모든 경우에 PS 와 Connection 을 닫기

deleteAll 이 JDBC 를 통해 DB 상태를 수정한다는 사실은 변하지 않는다. 이는 add 나 deleteOne 등도 마찬가지이다. 그외 커넥션을 가져오고 PS 를 실행하고 공유 리소스를 반환하는 작업들은 공통이며 기존 기능 맥락이 변하지 않는 한, 변하지 않는 부분이다.

하지만 PS 를 생성하는 부분은 메서드의 역할 (CUD), 데이터 베이스 컬럼 변경, 비즈니스 로직 변경에 의해 변경되는 부분이다.(즉, PS 를 생성하는 부분과 앞서 말한 부분은 변경이나 확장의 사유가 다르다)

따라서, “외부 기능 호출을 통해 PreparedStatement 생성” 은 Strategy 인터페이스에서 노출할 기능이 된다.

// UserDao.java
public void deleteAll() throws SQLException {
  // DB Connection 가져오기
  Connection c = this.dataSource.getConnection();

  // SQL 쿼리(statement)를 생성
  StatementStrategry strategy = new DeleteAllStrategy(); // 전략 생성 후 사용
  PreparedStatement ps = strategy.makePreparedStatement(c);
	...

// StatementStrategy.interface
public interface StatementStrategry {

    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

// DeleteAllStrategy.java
public class DeleteAllStrategy implements StatementStrategry {
    @Override
    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        // SQL 쿼리(statement)를 생성
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}
  • Context Method → UserDao 의 deleteAll 메서드, 커넥션을 가져오고 try-catch-finally 예외처리를 포함한다.
  • Strategy → makePreparedStatement 를 노출한다.
  • RunTime Strategy → Strategy 를 구현한 구현체로 DeleteAllStrategry 에 해당한다.

문제점과 원인

  • 기존 DAO 의 개별 메서드였던 기능들은 매번 Strategry 클래스가 있어야 사용 할 수 있다.
  • 컨택스트 메서드인 deleteAll 에서 이미 DeleteAllStrategy 를 사용하도록 고정되어 있어 전략 패턴의 장점인 느슨한 결합의 이점을 보지 못한다.
    • 전략 패턴은 인터페이스를 구현한 다양한 전략들을 바꿔 쓸 수 있는 것이 장점

    • 위의 경우 deleteAll 이 StatementStrategy 인터페이스 뿐만 아니라 구체적 구현 클래스를 알고 있기에 교체 불가능

      ⇒ 실질적으로 전략을 선택하고 생성하는 클라이언트와 변하지 않는 부분인 Context, context method 가 합쳐져 있어 발생하는 문제! 변하지 않는 부분도 같이 변해야하는 아이러니 발생

      ⇒ OCP 원칙 위배


해결 : 전략 패턴 (2) - Client, Context 의 분리

전략 패턴에서 Context 가 사용할 전략을 선택하는 것은 Context 를 사용하는 Client 가 선택하는 것이 일반적이다.

Client 의 역할을 포함하는 전략 패턴
DI 를 적용해 Client 가 전략을 선택 및 생성한 객체를 Context 에게 전달하고, Context 는 변하지 않는 로직을 구현한 메서드 안에서 전략을 사용한다.

DI 를 통해 전략 패턴의 장점을 일반적으로 활용한 구조

// JdbcContext.java
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) {
                    throw e;
                }

                try {
                    c.close();
                } catch (SQLException e) {
                    throw e;
                }
            }
        }
    }
}

컨택스트인 JdbcContext 는 JDBC 를 통해 DB 상태 수정을 하기 위해 수행되야하는 로직을 구현하고있다. 이는 기존 DAO 클래스의 책임이던 부분을 컨택스트 맥락으로 분리한 것이다.

“해결 : 전략 패턴 (1)” 의 전략을 사용하는 주체가 UserDao 가 아닌 Context 를 추가해 끼워넣는 것으로 이해하면 쉽다.

마이크로 DI
DI 는 다양한 형태로 적용 가능하다. 가장 중요한 핵심은 제 3자를 통해 두 오브젝트간 관계가 유연하게 설정되도록하는 부분이다. 따라서 제 3자는 별도의 클래스일 수도 있지만 클라이언트와 DI 관계에 있는 모든 구성요소가 하나의 클래스안에 담길 수도 있다. 이런 경우 DI 는 클래스내의 메서드 혹은 작은 코드 단위에서 발생 할 수 있다.

// UserDao.java
...
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) {
                throw e;
            }

            try {
                c.close();
            } catch (SQLException e) {
                throw e;
            }
        }
    }
}

public void deleteAll(
...
	StatementStrategy stmt = new DeleteAllStrategy();
  this.workWithStatementStrategy(stmt);

기존의 DI 가 UserDao 를 클라이언트, DI 가 필요한 JdbcContext 와 DeleteALlStrategy 를 각각의 클래스로 분리한 것과 다르게 클라이언트인 UserDao 의 deleteAll 메서드가 클라이언트면서 전략 클래스를 생성하고 관계를 만드는 책임도 같이 수행한다.
이또한 DI 이다!

// UserDao.java
public class UserDao {

    private JdbcContext jdbcContext;

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

    public void add(User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(
            new StatementStrategy() {
                @Override
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                    // SQL 쿼리(statement)를 생성
                    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 deleteAll() throws SQLException {
        this.jdbcContext.workWithStatementStrategy(
            new StatementStrategy() {
                @Override
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                    PreparedStatement ps = c.prepareStatement("delete from users");
                    return ps;
                }
            }
        );
    }
}

클라이언트인 deleteAll, add 메서드는 각자의 기능 맥락을 위해 필요한 PreparedStatement 를 만드는 전략을 생성하고, 생성한 전략을 컨택스트의 메서드 호출 인자로 넘겨주는 역할만을 하고있다.

코드가 아주 간결하고 이해하기 쉽다. 처음 코드로 부터의 변화를 생각하면 소름이 돋는다.

JDBC 를 사용하는 모든 DAO 는 jdbc 사용에 따른 컨택스트를 JdbcContext 를 재사용해 쉽게 자신의 비즈니스 로직에만 집중 할 수 있다. 또한 각자의 비즈니스 로직에 맞는 PreparedStatement 를 생성하는 작업은 makePreparedStatement 하나만 구현하면 되므로 UserDao 는 커넥션이 어떻게 만들어지는지, PreparedStatement 가 어떤 과정에 의해 실행되는지 전혀 알지 못한다.

중첩 클래스
다른 클래스 내부에 정의되는 클래스를 중첩 클래스라고 한다. 중첩 클래스는 독립 유무에 따라 스태틱과 내부 클래스로 구분되며 내부 클래스 중에서도 범위에 따라 3가지로 구분된다.

  • 스태틱 클래스
  • 내부 클래스
    • 멤버 내부 클래스 - 오브젝트 레벨에 정의
    • 로컬 클래스 - 메서드 레벨에 정의
      public void add(User user) throws SQLException {
      		
      		class AddStatement implements StatementStrategy {
      				public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                  // SQL 쿼리(statement)를 생성
                  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 stmt = new AddStatement();
      
          this.jdbcContext.workWithStatementStrategy(stmt);
      }
    • 익명 내부 클래스 - 스코프는 선언 위치에 따라 다르나 이름을 갖지 않는다.
      public void add(User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(
            new StatementStrategy() {
                @Override
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                    // SQL 쿼리(statement)를 생성
                    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 이므로 해당 인터페이스를 생성자로 사용했다.
      1. 클래스를 재사용 할 필요가 없는 경우

      2. 구현한 인터페이스 타입으로만 사용할 경우

        위의 2가지 조건이 적용될 경우 사용하기 좋다.


틈새 DI

위의 코드에서 의존관계와 관련해 눈에띄게 달라진 점은 기존 UserDao 가 기존 DataSource 에 의존하던 것이 JdbcContext 에 대한 의존으로 변경된 점이다. 의존관계 주입대상이 변경되었으므로 애플리케이션 컨택스트 xml 파일또한 변경되야하는데 한가지 의문이 생긴다.

“인터페이스가 아닌 구현체에 의존하는 빈을 주입하는 것도 DI 라고 할 수 있을까?”

의존관계 주입 개념을 충실히 따르기 위해서는 아래의 내용이 지켜져야한다.

  1. 의존하는 객체간에 인터페이스를 두어 클래스 레벨에서 서로간에 의존관계가 고정되지 않도록 한다.
  2. 런타임시에 의존할 오브젝트와의 관계를 다이나믹하게 주입한다.

위의 코드는 인터페이스가 없으므로 런타임이 아닌 설계시점에 의존 관계가 고정되고, 런타임 의존관계를 변경 할 수 없다. 그러나, 아래의 이유로 인터페이스를 사용하지 않는 의존성 주입을 고려해 볼 수 있다.

  1. JdbcContext 가 스프링 컨테이너 싱글톤 레지스트리에서 관리되는 싱글톤 빈으로 사용하면 좋을때
    1. 자체적으로 변화시키는 상태를 가지고 있지 않아 싱글톤 레지스트리 등록이 가능하다.
    2. 해당 클래스는 여러 클래스에서 공유해 사용하는 것이 이상적이다.
  2. DI 를 통해 다른 빈에 의존하고 있을때
    1. JdbcContext 는 Datasource 프로퍼티를 주입 받아야한다. 주입 받기 위해서는 자신도 빈으로 등록되야한다.
  3. 의존하는 두 객체가 매우 긴밀한 관계를 가지고 있을때
    1. UserDao 와 JdbcContext 는 매우 강한 응집도를 가지고있다. UserDao 가 JDBC 가 아닌 JPA, 하이버네이트 등의 ORM 을 사용한다면 JdbcContext 는 통째로 변경된다. 테스트상에서도 다른 구현체로 바꾸어 테스트해야할 이유가 없다 (ConntectionMaker 처럼)

이런 경우 인터페이스가 아닌 구현체를 주입해도 괜찮다. 적절한 이유가 있기 때문이다. 물론 인터페이스를 두는 것도 나쁜 선택지가 아니다.

항상 이부분이 궁금했는데 좋은 답변을 얻은 것 같다. DI 에 대한 원칙을 엄격히 고수하기 보다 DI 를 사용하는 이유에 대해 보다 정확히 이해하고 사용하는 것이 중요하다는 결론을 내렸다.


템플릿과 콜백

템플릿과 콜백이란?

템플릿
도형자, 도안, 모양자와 같이 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 말한다.
고정된 틀(변하지 않는 부분)에 변할 수 있는 부분을 넣어 사용한다.
템플릿 메서드는 템플릿을 슈퍼 클래스에 정의된 일련의 기능 맥락으로 하고 변하는 부분, 확장 가능한 부분을 서브 클래스에서 재정의한 기능으로 사용한다.

콜백
실행되는 것을 목적으로 다른 오브젝트의 메서드에 전달되는 오브젝트를 말한다.
일반적으로 파라미터로 전달되는 오브젝트는 값을 참조하기 위해 사용하나 콜백의 경우 특정 로직을 담은 메서드 실행을 위해 전달한다. 자바에서 메서드 자체를 파라미터로 전달할 방법이 없어 메서드가 담긴 오브젝트를 전달한다. 따라서 Functional Object 라고도 한다.

add(30, 10, (sum : number) => console.log(sum))

위는 타입스크립트의 콜백이다. 메서드 자체를 파라미터로 넘기고있다.

add(30, 10, new Interface() {
	 void printResult(Integer sum){
			System.out.println(sum.toString());
	 }
}

반면, 자바의 경우 익명 클래스(내부 클래스)를 사용해 처리한다.

특징

  • 전략 패턴의 전략을 제공하는 인터페이스는 복수개의 메서드를 포함할 수 있으나 템플릿/콜백의 경우 일반적으로 단일 메서드 인터페이스를 사용한다. → 기능 맥락 중 특정 기능을 위해 한번 호출되는 경우가 대부분
  • 일반적으로 콜백 인터페이스의 메서드에는 파라미터가 존재하고 해당 파라미터는 템플릿의 작업 흐름 중 만들어지는 컨텍스트의 정보를 얻기 위해 사용된다. → 위의 템플릿에서는 Connection 리소스를 받아오기 위해 사용된다.

JdbcContext 에 적용된 템플릿 콜백

  • Client
    // UserDao.java
    public void add(User user)
    • Client 는 IoC 컨테이너가 자바 빈간의 관게를 설정하는 것 처럼 Template 과 Callback 을 생성하고 관계를 설정하는 역할을 한다.
    • Callback 이 참조할 정보또한 Client 에서 전달한다. 직접적으로 Template 으로 부터 받아오는 참조 객체는 Connection 이며 간접적으로 스코프내에서 참조 가능한 객체가 User 이다.
  • Template
    // JdbcContext.java
    public void workWithStatementStrategy(StatementStrategy stmt)
    • 작업 흐름에 따라 작업을 진행하다 내부에서 생성한 참조 정보를 통해 Callback 오브젝트의 메서드를 호출한다.
    • 작업 흐름에서 생성한 참조 정보는 Connection 이며 이를 Callback 에 전달한다.
  • Callback
    // UserDao.java
    new StatementStrategy() {
        @Override
        public PreparedStatement makePreparedStatement(Connection c)
    		...
    • Callback 은 Client 로 부터 받은 정보와 Template 이 제공한 참조 정보를 통해 작업을 수행하고 해당 결과를 Template 에게 반환한다.

정리

  • 메서드 레벨 DI 방식의 전략 패턴 구조를 따른다고 이해하면 쉽다.
    • Template 을 호출하는 것과 DI 작업이 동시에 발생한다.
  • Callback 오브젝트가 내부 클래스로 자신을 생성한 Client 의 정보를 직접 참조한다.
  • 템플릿/콜백은 전략 패턴 + DI + 내부 클래스 사용 전략 인 활용법이다. → 수동 DI 와 전략 패턴에 대한 이해가 필요
  • 복잡한 템플릿인 경우 여러개의 콜백을 받기도 하며 일반적으로 템플릿 호출 결과를 클라이언트에 반환한다.

콜백 재활용 적용

public void add(User user) throws SQLException {
    this.jdbcContext.executeSql("insert into users(id, name, password) values(?,?,?)",
        user.getId(), user.getName(), user.getPassword());
}

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

이전에 비해 UserDao 의 메서드를 구성하는 코드의 양이 줄었다. StatementStrategy 를 생성하고 전략을 구성하는 코드가 JdbcContext 의 executeSql 메서드로 통합되었기 때문이다.

public void executeSql(final String query, String... args) throws SQLException {
    this.workWithStatementStrategy(
        new StatementStrategy() {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement(query);

                for (int i = 0; i < args.length; i++) {
                    ps.setString(i + 1, args[i]);
                }

                return ps;
            }
        }
    );
}
  1. 메서드 추출 기법 사용, JdbcContext.workWithStatementStrategy 호출과 workWithStatementStrategy 의 파라미터로 StatementStrategy 를 전달하던 과정을 추출
  2. executeSql 은 UserDao 에서 관리하기 보다 JdbcContext 와 극히 밀접한 기능이므로 JdbcContext 로 이전

스프링의 JdbcTemplate

알고보니 스프링에서 제공하던 기능이었다?

JdbcTemplate
스프링은 JDBC 를 사용하는 DAO 에서 사용가능한 다양한 템플릿과 콜백을 제공한다.
거의 모든 JDBC 코드에서 사용가능한 템플릿, 콜백을 제공하고 반복 사용 패턴을 가지는 콜백은 다시 템플릿에 결합시켜 사용 할 수 있도록 처리되어 있다.

템플릿/콜백 패턴을 이해하고 있다면 매우 쉽게 사용할 수 있다. 아래는 JdbcContext 를 JdbcTemplate 으로 대체한 코드이다.

package springbook.user.dao;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import springbook.user.domain.User;

public class UserDao {

    private JdbcTemplate jdbcTemplate;
    private RowMapper<User> userMapper = new RowMapper<User>() {
        @Override
        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;
        }
    };

    public void setDataSource(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void add(User user) throws SQLException {
        // (방법 1) 익명 클래스를 생성해 주입
//        this.jdbcTemplate.update(
//            new PreparedStatementCreator() {
//                @Override
//                public PreparedStatement createPreparedStatement(Connection con)
//                    throws SQLException {
//                    PreparedStatement ps = con.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;
//                }
//            }
//        );

        // (방법 2) executeSql 과 같이 쿼리와 가변인자만으로 호출
        this.jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",
            user.getId(), user.getName(), user.getPassword());
    }

    public void deleteAll() throws SQLException {
        // (방법 1) 익명 클래스를 생성하고 템플릿 호출시 전달
//        this.jdbcTemplate.update(
//            new PreparedStatementCreator() {
//                @Override
//                public PreparedStatement createPreparedStatement(Connection con)
//                    throws SQLException {
//                    return con.prepareStatement("delete from users");
//                }
//            }
//        );

        this.jdbcTemplate.update("delete from users");
    }

    public int getCount() throws SQLException {
//        return this.jdbcTemplate.query(
//            new PreparedStatementCreator() {
//                @Override
//                public PreparedStatement createPreparedStatement(Connection con)
//                    throws SQLException {
//                    return con.prepareStatement("select count(*) from users");
//                }
//            },
//            new ResultSetExtractor<Integer>() {
//                @Override
//                public Integer extractData(ResultSet rs) throws SQLException, DataAccessException {
//                    rs.next();
//                    return rs.getInt(1);
//                }
//            }
//        );

        return jdbcTemplate.queryForInt("select count(*) from users");
    }

    public User getOne(String id) throws EmptyResultDataAccessException {
//        return (User) this.jdbcTemplate.query(
//            new PreparedStatementCreator() {
//                @Override
//                public PreparedStatement createPreparedStatement(Connection con)
//                    throws SQLException {
//                    PreparedStatement ps = con.prepareStatement("select * from users where id = ?");
//                    ps.setString(1, id);
//
//                    return ps;
//                }
//            },
//            new RowMapper<User>() {
//                @Override
//                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;
//                }
//            }
//        ).get(0);

        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 asc",
            this.userMapper
        );
    }
}

userMapper 인스턴스 변수에 RowMapper 인터페이스를 통해 생성한 익명 클래스로 초기화함으로서 getOne, getAll 메서드에서 반복되는 콜백 코드를 제거했다.

추가적인 개선 필요 사항

  • JdbcTemplate 과의 느슨한 결합 유지
    • JDBC 는 스프링에서 DAO 를 구현하는 표준이지만 더 낮은 결합도를 위해서라면 JdbcOperations 에 의존하도록 하고 DI 를 통해 JdbcTemplate 를 DI 하도록 할 수 있다.
  • UserMapper 를 독립적인 빈으로 생성하고 DI
    • userMapper 프로퍼티는 한번 만들어지면 변경되지 않는 프로퍼티성 특징을 갖는다.
    • UserMapper 를 독립된 빈으로 만들고 XML 설정을 통해 User 의 필드와 테이블의 필드를 맵핑 → UserDao 에서 필드나 맵핑 전략의 변경에 독립적으로 구성 가능
  • SQL 쿼리를 UserDao 가 아닌 외부 리소스에 담고 읽어와 사용
    • 쿼리 최적화, 필드명 변경에 UserDao 가 독립적 구성

마치며

해당 글은 토비 스프링 3.1 의 챕터 3를 공부하며 기록한 내용을 담고 있다.

핵심은 변경 주기에 따라 변하지 않는 부분은 컨텍스트로, 변하는 부분은 전략으로 구현하여 OCP 원칙에 따른 애플리케이션 설계를 하는 것이다. 또한 변하는 부분도 변경 사유에 따라 구분하는 것이 더욱 효과적인 설계를 할 수 있도록 한다는 점도..

이 책이 정말 좋은 점은 전략 패턴을 적용하기 앞서 예시 코드에서 어떤 부분이 반복되고, 재활용 할 수 있으며 개선될 여지가 있는지 친절하게 설명해준다는 점이다. 개선될 여지와 개선 후 효과에 대해 이해하고나니 디자인 패턴의 필요성과 효용에 대해 크게 동감하게되어 쉽게 이해 할 수 있게 된다.

사실 오늘 공부한 내용을 실무에서 적용할려하면 가장 큰 허들은 변하는 부분과 변하지 않는 부분을 분석하는 파트인 것 같다. 지금까지는 대부분 데이터베이스 테이블을 중심으로 쌓아나가며 개발을 했는데, 비즈니스 로직을 중심으로 개발과 설계를 하는 습관을 들여야 분석하기가 쉬워질 것 같다.

찐막 정리

  • 공유 리소스 반환이 필요한 경우 try-catch-finally
  • 작업 흐름은 반복되고 일부 기능만 변경될 코드 → 전략 패턴 적용
  • 여러 종류 전략을 다양하게 구성해야한다면 클라이언트가 전략 생성 및 주입
    • 컨텍스트는 별도의 빈으로 DI 받거나 클라이언트 내부에서 생성, 주입해도 좋음
  • 템플릿/콜백 패턴
    • 템플릿과 콜백 사이에 주고 받는 정보에 집중
    • 콜백의 재활용
    • 제네릭의 활용
  • JdbcTemplate 클래스
profile
개발을 사랑하는 개발자. 끝없이 꼬리를 물며 답하고 찾는 과정에서 공부하는 개발자 입니다. 잘못된 내용 혹은 더해주시고 싶은 이야기가 있다면 부디 가르침을 주세요!

0개의 댓글