3장. 템플릿

지하나·2021년 9월 1일
0

토비의 스프링 v1

목록 보기
3/6
post-thumbnail

변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어주는 것이 바로 이 개방 폐쇄 원칙이다.
템플릿이란 이렇게 바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다. -본문 209p


3.1 다시 보는 초난감 DAO

다시 초난감 DAO로 돌아와서 코드를 살펴보면.. JDBC API의 기본적인 사용 과정에서 잠시 잊혀둔 부분이 있었다. 바로 작업 중에 생성된 Connection, Statement, ResultSet과 같은 리소스를 작업이 끝난 후 반드시 닫아주는 것과 예외 처리다.

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

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

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

Connection과 PreparedStatement는 보통 풀 방식으로 운영된다. 즉, 미리 제한된 수의 리소스 객체를 저장해두고 pool에 저장해두었다가 클라이언트의 요청이 오면 이를 할당하고, 반환하면 다시 풀에 넣어두는 방식이다. 위와 같이 DB 풀은 요청 시 할당해준 커넥션을 close()해서 다시 풀에 넣엇다가 다음 커넥션 요청이 있을 때 재사용할 것이다.

만약 이 때 중간에 예외가 발생하여 리소스를 반환받지 못한 채로 운영된다면, 후에 커넥션 풀에 여유가 없어지고 리소스가 모자라서 서버가 중단될 수 있다. 따라서 리소스 반환은 반드시 지켜주어야하기 때문에 finally 구문에서 close를 시켜준다. 이 때 close하는 순서는 만들어진 순서의 반대로 하는 것을 원칙으로 한다.

public class UserDao {
    public void deleteAll() throws SQLException {
        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) {
                }
            }
        }
    }
}

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

마찬가지로 다른 요청들에도 위와 같이 에러처리와 finally 구문을 넣어주다보면 코드가 계속 반복되는 부분을 생긴다. 앞에서 DAO 생성과 DB 연결 기능을 분리한 것과 같이 변하지 않고 중복되는 부분로직에 따라 확장이 가능하고 자주 변하는 부분은 분리해서 관리해주어야 한다.

메소드에서 변하지 않고 고정되는 부분과 로직에 따라 변하는 부분은 무엇일까?

변하지 않는 부분은 커넥션을 가져오고, 에러 처리와 리소스를 반환하는 부분이 될 것이고,
변하는 부분은 PrepareStatement로 DB 요청문을 생성하는 부분이다.

메소드 추출

먼저 단순하게 메소드 추출하는 방법을 생각해보자.

public class UserDao {
    public void deleteAll() throws SQLException {
        ...
        try {
            c = dataSource.getConnection();
            
            ps = makeStatement(c, "delete from users");
            
            ps.executeUpdate();
        } catch (SQLException e) {
        ...
    }

    private PreparedStatement makeStatement(Connection c, String stmtStr) throws SQLException {
        return c.prepareStatement(stmtStr);
    }
}

그런데 이렇게 해도 메르트가 없는 것이,, 추출한 메소드를 getCount()에 적용할 수 없다. 게다가 재사용이 필요한 부분은 분리시키고 남은 메소드이고, 추출한 메소드는 계속 변하고 확장할 부분이다. (주객이 전도된 너낌,,)

좀 더 근본적으로,, 커넥션을 만들고 에러처리를 하는 부분을 재사용할 수 있도록 분리해내야 한다.

템플릿 메소드 패턴의 적용

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

템플릿 메소드 패턴을 여기에 적용해보자. 변하지 않는 부분인 makeStatement() 메소드를 추상 메소드로 선언한다. UserDao는 추상클래스가 되고, 변하지 않는 부분(에러 처리와 리소스 반환 부분)을 갖게 한다.

public abstract class UserDao {
    abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
    
    public void doStatement() throws SQLException {
        ...
        try {
            c = dataSource.getConnection();
            ps = makeStatement(c);
            ps.executeUpdate();
        } catch (SQLException e) {
        ...
        }
    }
}

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

위와 같이 상위 클래스에서 커넥션을 만들고, 에러 처리를 하는 컨텍스트 로직을 둔다. 상속을 통해 하위 클래스에서 PreparedStatement을 필요에 맞게 변경할 수 있게 한다. makeStatement()는 하위 클래스에서 구현하는 것이다.

그러면 UserDao 클래스의 기능을 변경해야 할 때마다 상속을 통해 자유롭게 UserDao를 확장하면 된다...? 음,, DAO 로직마다 상속 받아서 새로운 클래스를 만들어야한다. 문제가 있어보인다.

전략 패턴의 적용

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

이번엔 보다 유연하게 확장할 수 있게 해주는 인터페이스를 적용해보자. 앞에서부터 계속 똑같이,, 변하지 않는 부분(context)은 JDBC를 이용해 DB를 업데이트 하는 작업이며, 변하는 부분(전략)인 PreparedStatement를 만들어주는 부분이다. 따라서 이 부분을 인터페이스로 만들어서 빼도록 하자.

전략 패턴에서는 일반적으로 Context에서 어떤 전략을 사용할 것인지는 Context를 사용하는 앞단인 Client에서 결정한다. 즉, Client가 전략을 직접 선택하고 생성해서 Context에 전달하고, Context는 전달받은 전략의 구현체를 사용한다.

public interface StatementStrategy {
    PreparedStatement makeStatement(Connection c) throws SQLException;
}

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

이제 DeleteAllStatement를 생성해주는 메소드는 Client에 들어갈 것이다. 컨텍스트에 해당하는 부분은 별도의 메소드로 분리시켜 정리해보자. 그러면 deleteAll() 메소드는 다음과 같이 바뀔 것이다. 클라이언트는 전략 클래스를 생성해서 컨텍스트에 파라미터로 전달한다.

public class UserDao {
    public void deleteAll() throws SQLException {
        // 전략 클래스의 선택 및 생성 
        StatementStrategy stmt = new DeleteAllStatement();
        // 컨텍스트 호출 및 전략 오브젝트 전달 
        jdbcContextWithStatementStrategy(stmt);
    }

    private void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException {
        ...
        try {
            c = dataSource.getConnection();
            // 컨텍스트는 전달받은 전략 클래스로 statement를 만듦
            ps = stmt.makeStatement(c);
            ps.executeUpdate();
        } catch (SQLException e) {
        ...
        }
    }
}

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

위의 구조를 그려보면 다음과 같다.

사실 위의 코드는 1장에서 UserDao와 ConnectionMaker 분리 과정과 같다. UserDao가 필요로 하는 전략(ConnectionMaker)의 특정 구현체(DConnectionMaker)를 Client(UserDaoTest)가 만들어서 넘겨준 것과 동일하다. 또는 이렇게 전략이 되는 오브젝트를 생성하고 Context에 넘겨주는 책임을 분리시킨 ObjectFactory였던 거고, 이것의 개념을 더 일반화한 것이 DI였다.

이렇게 코드를 분리한 것이 크게 장점이 보이지는 않지만, 구조적인 면에서 관심사의 분리를 유연한 확장을 가능하도록 개선한 데에 의미를 갖느다.


3.3 JDBC 전략 패턴의 최적화

그럼 이번에는 add() 메소드에도 똑같이 적용해보자. 이 경우는 deleteAll()와 달리 user의 부가적인 정보가 필요하다. 위에서 만든 StatementStrategy 인터페이스에서 이번엔 AddStatment라는 전략을 만들어보자.

public interface StatementStrategy {
    PreparedStatement makeStatement(Connection c) throws SQLException;
}

public class AddStatement implements StatementStrategy {
    // 오브젝트를 전달받을 생성자와 변수 
    private User user;
    public AddStatement(User user) {
        this.user = user;
    }
    
    public PreparedStatement makeStatement(Connection c) {
        ...
        ps.setString(1, user.getId());
        ps.setString(2, user.getName());
        ...
        return ps;
    }
}

public class UserDao {
    public void add(User user) throws SQLException {
        StatementStrategy stmt = new AddStatement(user);
        jdbcContextWithStatementStrategy(stmt);
    }
    public void deleteAll() throws SQLException {
        StatementStrategy stmt = new DeleteAllStatement();
        jdbcContextWithStatementStrategy(stmt);
    }
    private void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException {
        ...
        try {
            c = dataSource.getConnection();
            ps = stmt.makeStatement(c);
            ps.executeUpdate();
        } catch (SQLException e) {
        ...
        }
    }
}

변하는 부분과 변하지 않는 부분을 제대로 분리가 되었고, 이 구조를 그대로 add()deleteAll()에 둘 다 적용할 수 있게 되었다. 게다가 UserDao 클래스 하나에 add()deleteAll()를 같이 둘 수 있게 되었다. 아름답군,,

조금만 더 욕심을 내보자. 보면 메소드마다 StatementStrategy를 가져와서 계속 새로운 구현 클래스를 만들어주고 있다. 또한 add() 메소드의 경우와 같이 부가적인 정보가 필요할 때는, 오브젝트(User)를 전달받을 생성자와 이를 저장해둘 멤버 변수가 있어야 한다. 이거까지 정리할 순 없을까?

로컬 클래스

전략 클래스를 매번 새로 만들지 말고 그냥 특정 전략을 사용할 DAO 클래스 내부에서 정의해버리면 어떨까?
DeleteAllStatmentAddStatementUserDao에서만 사용하니까 로직에 강하게 결합되어 있다. 그러니까 그냥 AddStatement 구현하는 부분을 add() 메소드 안에 중첩 클래스로 넣어버리자.

public class UserDao {
    public void add(final User user) throws SQLException {
        class AddStatement implements StatementStrategy {
            public PreparedStatement makeStatement(Connection c) {
                ...
                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                ...
                return ps;
            }
        }

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

그러면 위와 같이 user 정보가 로컬 변수화되서 생성자나 멤버 변수 없이 바로 가져다 쓸 수 있게 되고, 메소드마다 클래스를 새로 생성하지 않아도 된다.

익명 내부 클래스

사실 여기서 아예 클래스 이름까지 없애버릴 수 있다. 자바의 익명 내부 클래스를 사용하는 것이다.

익명 내부 클래스는 이름을 갖지 않는 클래스다. 클래스 선언과 오브젝트 생성이 결합된 형태로 만들어지며 상속할 클래스나 구현할 인터페이스를 생성자 대신 사용해서 다음과 같은 형태로 만들어 사용한다. 클래스를 재사용할 필요가 없고, 구현한 인터페이스 타입으로만 사용할 경우에 유용하다.
new 인터페이스이름() { 클래스 본문 };

public class UserDao {
    public void add(final User user) throws SQLException {
        StatementStrategy stmt = new StatementStrategy() {
            public PreparedStatement makeStatement(Connection c) {
                ...
                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                ...
                return ps;
            }
        }
        
        jdbcContextWithStatementStrategy(stmt);
    }
    
    public void deleteAll() throws SQLException {
    	StatementStrategy stmt = new StatementStrategy() {
            public PreparedStatement makeStatement(Connection c) {
                return c.prepareStatement("delete from users");
            }
        }
        
        jdbcContextWithStatementStrategy(stmt);
    }
    
    private void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException {
        ...
        try {
            c = dataSource.getConnection();
            ps = stmt.makeStatement(c);
            ps.executeUpdate();
        } catch (SQLException e) {
        ...
        }
    }
}

어려울 게 없는게 그냥 전략이었던 인터페이스 클래스의 구현체를 내부에서 이름없이 정의해버리는 것이다. 이처럼 전략을 익명 내부 클래스로 정의해서 사용하는 패턴을 템플릿 콜백 패턴이라고 한다. 전략 패턴의 변형으로 전략의 구현 클래스를 새로 정의하지 않아도 된다.


3.4 컨텍스트와 DI

그런데 사실 jdbcContextWithStatementStrategy() 매소드는 다른 DAO에서도 다분히 사용할 수 있는 기능이다. 그러니 이를 UserDao 밖으로 분리해두면 어떨까?

이렇게 바꾸면 한 가지 큰 변화가 생기는데,, dataSource를 주입시켜주어야 할 클래스가 UserDao가 아니라 jdbcContextWithStatementStrategy() 쪽이 된다. 코드로 보면 아래와 같다.

public class JdbcContext {
    private DataSource dataSource;
    
    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    public void doStatementStrategy(StateStrategy stmt) throws SQLException {
        ...
        try {
            c = dataSource.getConnection();
            ps = stmt.makeStatement(c);
            ps.executeUpdate();
        } catch (SQLException e) {
        ...
        }
    }
}

그러면 이제 UserDaoJdbcContext를 주입받아야 한다.

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

    public void deleteAll() throws SQLException {
    	StatementStrategy stmt = new StatementStrategy() {
            public PreparedStatement makeStatement(Connection c) {
                return c.prepareStatement("delete from users");
            }
        }
        
        // jdbcContextWithStatementStrategy(stmt);
        this.jdbcContext.doStatementStrategy(stmt);
    }
    public void add(final User user) throws SQLException {
        StatementStrategy stmt = new StatementStrategy() {
            public PreparedStatement makeStatement(Connection c) {
                ...
                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                ...
                return ps;
            }
        }
        
        // jdbcContextWithStatementStrategy(stmt);
        this.jdbcContext.doStatementStrategy(stmt);
    }
}

JdbcContext의 특별한 DI

jdbcContextWithStatementStrategy() 매소드를 클래스로 분리해내고 보니 UserDao는 이제 인터페이스를 통한 의존성이 아닌, JdbcContext라는 구현체에 직접 의존하고 있다. 즉, UserDaoJdbcContext 사이에는 인터페이스를 사용하지 않고 DI를 적용했다.

스프링에서 DI는 기본적으로 인터페이스를 사이에 두고 주입을 시켜서 의존성을 자유롭게 바꿀 수 있는 패턴을 사용하는 것이 맞다. 하지만 DI의 개념을 넓은 관점에서 보면, 결국은 "객체의 생성과 관계 설정에 대한 제어권을 외부에 위임" 을 의미한다.

때문에 이러한 구조가 조금 어색해보일 수 있지만 이 경우는 JdbcContext의 로직이 바뀔 가능성이 없으므로 괜찮다.

그래도 JdbcContext는 구체 클래스인데 DI안하고 그냥 생성하면 안될까? UserDao가 직접 생성하는 방법을 쓰지않고 굳이 DI 구조로 만들어야 하는 이유는 무엇일까?

그 이유는 우선 JdbcContext는 그 자체로 변경이 되는 상태 정보를 갖고 있지 않고, JDBC 컨텍스트 메소드를 제공해주는 서비스 오브젝트로서 의미가 있다. 따라서 스프링 컨테이너에 싱글톤으로 등록 되어 여러 오브젝트에서 공유하는 것이 구조적으로 맞다.

그리고 무엇보다 JdbcContext가 주입받고 있는 DataSource가 빈이기 때문에 역시 빈으로 등록해야 한다. 다른 빈을 주입 받기 위해서라도 스프링 빈으로 등록되어야 한다.

DI를 위해서는 주입되는 오브젝트와 주입받는 오브젝트 양쪽 모두 스프링 빈으로 등록돼야 한다. 스프링이 생성하고 관리하는 IoC 대상이어야 DI에 참여할 수 있기 때문이다. -본문 236p

코드를 이용하는 수동 DI

위와 같이 스프링 빈으로 등록해서 DI를 주입하는 방법 대신 UserDao 내부에서 직접 DI를 할 수도 있다. 물론 이렇게 하면 JdbcContext는 이제 스프링 빈이 아니니까 DI 컨테이너를 통해 DI 받을 수는 없다. 그럼 JdbcContext가 의존하고 있는 DataSource는 어떻게 주입시켜줄까?

바로 DataSource를 UserDao에 대신 주입시켜서 UserDao가 주입받은 DataSource를 JdbcContext에 주입하는 것이다.

public class UserDao {
    private JdbcContext jdbcContext;
    
    public void setDataSource(DataSource dataSource) {
        // DI 대신 UserDao가 직접 JdbcContext 생성 
        this.jdbcContext = new JdbcContext();
        // UserDao가 주입받은 DataSource를 JdbcContext에 주입
        this.jdbcContext.setDataSource(dataSource);
    }
    
    // 외부 다른 메소드는 영향x
    public void deleteAll() throws SQLException {
    	StatementStrategy stmt = new StatementStrategy() {
            public PreparedStatement makeStatement(Connection c) {
                return c.prepareStatement("delete from users");
            }
        }
        this.jdbcContext.doStatementStrategy(stmt);
    }
}

UserDao의 메소드 입장에서는 기존 코드(빈으로 DI 방식)나 위의 코드(수동으로 DI 방식)나 JdbcContext를 사용하는 데는 구분이 없다. JdbcContext를 외부에서 빈으로 가져왔는지 내부에서 직접 만들었는지 알지 못한다.

이와 같이 수정자 메소드에서 코드를 이용해 직접 DI 하는 것도 스프링에서 종종 사용하는 기법이다.

위의 두 가지 방법을 비교해보면,, 다음과 같다.

빈으로 등록해서 DI하는 방법수동으로 DI하는 방법
장점오브젝트 사이에 의존 관계가 설정 파일에 명시됨JdbcContext와 UserDao 관계가 추상화됨
단점스프링 DI의 근본 원칙에 부합하지 않음
(인터페이스를 통하지 않고 구체 클래스와 관계맺음)
싱글톤 구조가 아님

3.5 템플릿과 콜백

전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 패턴을 템플릿 콜백 패턴이라고 했다. 여기서 템플릿은 전랙 패턴의 컨텍스트, 콜백은 익명 내부 클래스로 만들어지는 오브젝트를 뜻한다. 위에서 살펴본 코드를 다시 보자.

// 콜백 인터페이스
public interface StatementStrategy {
    PreparedStatement makeStatement(Connection c) throws SQLException;
}

// 클라이언트
public class UserDao {
    private JdbcContext jdbcContext;
    public void setDataSource(DataSource dataSource) {
        this.jdbcContext = new JdbcContext();
        this.jdbcContext.setDataSource(dataSource);
    }

    public void add(final User user) throws SQLException {
        this.jdbcContext.doStatementStrategy(
            // 콜백
            new StatementStrategy() {
            public PreparedStatement makeStatement(Connection c) {
                ...
                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                ...
                return ps;
            }
        );
    }
}

public class JdbcContext {
    private DataSource dataSource;
    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    // 템플릿
    public void doStatementStrategy(StateStrategy stmt) throws SQLException {
        ...
        try {
            c = dataSource.getConnection();
            // 컨텍스트의 정보를 파라미터로 전달받음 
            ps = stmt.makeStatement(c);
            ps.executeUpdate();
        } catch (SQLException e) {
        ...
    }
}

템플릿/콜백의 특징

전략 패턴에서의 전략과 달리, 템플릿 콜백 패턴에서의 콜백은 보통 단일 메소드 인터페이스를 사용한다. 보통 템플릿의 작업 흐름 중 한 번만 호출되는 경우가 일반적이고, 그러한 호출을 또 전략마다 자유롭게 변경하기 위해서다. 일반적으로 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스를 콜백이라고 보면 된다.

만약 하나의 템플릿에 여러 가지 전략이 필요하다면 콜백 오브젝트를 여러번 사용할 수 있다.

콜백 인터페이스는 보통 템플릿의 작업 흐름 중에 만들어지는 컨텍스트의 정보를 파라미터로 전달받는다.
=> 콜백 인터페이스 StatementStrategy는 템플릿인 doStatementStrategy의 작업 흐름 중 생기는 컨텍스트 정보인 커넥션을 파라미터로 받는다

클라이언트의 역할은 템플릿 안에서 실행될 로직을 담은 콜백 오브젝트를 만들고, 콜백이 참조할 정보를 제공한다.
=> UserDao가 콜백 오브젝트를 익명 내부 클래스로 만들고 콜백이 참조하도록 User 정보를 제공한다

클라이언트가 템플릿의 메소드를 호출할 때 콜백이 파라미터로 전달되면서, 메소드 레벨에서 DI가 일어난다. 클라이언트가 템플릿의 기능을 호출하는 것과 동시에 템플릿이 사용할 콜백 인터페이스를 구현한 오브젝트를 메소드를 통해 주입해주는 DI 작업이 일어난다.
=> this.jdbcContext.doStatementStrategy( new StatementStrategy() {...} )

템플릿 콜백 방식에서는 매번 메소드 단위로 사용할 오브젝트를 새롭게 전달받는 것이 특징이다. 콜백 오브젝트가 내부 클래스로서 클라이언트 메소드 내의 정보를 직접 참조한다는 것도 템플릿 콜백 패턴의 특징이다.

편리한 콜백의 재활용

하지만 그래도 익명 내부 클래스는 코드 가독성이 좋지는 않는 것 같다. 익명 내부 클래스의 사용을 최소화해보자.

우선 deleteAll() 메소드를 보면 쿼리문 하나 만드는게 전부다. 그리고 이런 식의 콜백 오브젝트는 반복될 가능성이 많다. 쿼리문을 파라미터로 받아서 쿼리를 만들어주는 것으로 리팩토링하자. 그러면 이렇게 뽑아낼 메소드는 다른 Dao 클래스에서도 재사용되도록 JdbcContext 클래스의 매소드로 옮겨줄 것이다.

public class JdbcContext {
    public void executeQuery(final String query) throws SQLException {
        doStatementStrategy(new StatementStrategy() {
            public PreparedStatement makeStatement(Connection c) throws SQLException {
                return c.prepareStatement(query);
            }    
        });
    }
    
    private void doStatementStrategy(StateStrategy stmt) throws SQLException {
        ...
        try {
            c = dataSource.getConnection();
            // 컨텍스트의 정보를 파라미터로 전달받음 
            ps = stmt.makeStatement(c);
            ps.executeUpdate();
        } catch (SQLException e) {
        ...
        }
    }
}

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

add() 메소드와 같이 쿼리문을 만들 때 바인딩될 정보가 필요한 경우는 아래와 같이 가변인자를 사용하면 된다.

public class JdbcContext {
    public void executeQuery(final String query, String... args) throws SQLException {
        doStatementStrategy(new StatementStrategy() {
            public PreparedStatement makeStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement(query);    
                if (args != null) {
                    pos = 0;
                    for (String arg : args) {
                        pos++;
                        ps.setString(pos, arg)
                    }
            	}
            	return ps;
            });
        }
    }
}

public class UserDao {
    public void add(User user) throws SQLException {
        this.jdbcContext.executeQuery("insert into users(id, name, password) values(?,?,?)", user);
    }
}

3.6 스프링의 JdbcTemplate

지금까지 템플릿 콜백의 개념과 기본적인 구조에 대해 공부했다. 스프링에서는 JDBC를 이용하는 DAO에서 사용할 수 있도록 준비된 다양한 템플릿/콜백 기능들이 있다. 앞에서 만들었던 JdbcContext는 JDBC에서 제공하는 기본 템플릿인 JdbcTemplate으로 대체해보도록 하자.

public class UserDao {
    // private JdbcContext jdbcContext;
    private JdbcTemplate jdbcTemplate;
    
    public void setDataSource(DataSource dataSource) {
        // this.jdbcContext = new JdbcContext();
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
}

update()

앞에서는 StatementStrategy 인터페이스를 만들고, makeStatement() 메소드에서 전략에 맞는 기능을 구현했다. 그리고 이 콜백 메소드의 실행하는 템플릿은 JdbcContext에서 doStatementStrategy()라는 메소드를 통해 이루어졌었다.

JdbcTemplate에서 이에 대응하는 것이 PreparedStatementCreator 인터페이스와 콜백 메소드인 createPreparedStatment(), 그리고 템플릿 메소드는 update()가 있다.

public class UserDao {
    public void deleteAll() {
        // update(PreparedStatementCreator psc)
        this.jdbcTemplate.update(new PreparedStatementCreator() {
            public PreparedStatement createPreparedStatement(Connection c) throws SQLException {
                return c.preparedStatement("delete from users");
            }
        }
    }
}

그리고 쿼리문을 넣어주면 템플릿부터 콜백까지 한 번에 처리를 해주는 excuteQuery() 메소드도 추가로 만들었었는데 JdbcTemplate에는 이름은 같지만 파라미터가 다른 update() 메소드가 있다. 바로 위에서 사용하던 update()와 아래의 update()는 다른 것이다.

public class UserDao {
    public void deleteAll() {
        // update(String sql)
        this.jdbcTemplate.update("delete from users");
    }
}

query()와 정수값을 위한 queryForObject()

2장 테스트에서 잠시 나왔던 getCount()는 USER 테이블의 레코드 개수를 카운팅하는 메소드이다. query()는 PreparedStatementCreator 콜백과 ResultSetExtractor 콜백을 파라미터로, 콜백을 2개를 받는다.

여기서 PreparedStatementCreator 콜백은 앞에서와 똑같이 커넥션을 연결해서 PreparedStatement를 만들어 반환하고, 템플릿에서 쿼리를 수행하여 받은 결과를 ResultSet으로 받아 ResultSetExtractor 콜백에게 넘겨주고, 여기서 원하는 값을 추출해 템플릿에게 반환한다.

public class UserDao {
    public int getCount() {
        return this.jdbcTemplate.query(
            // 첫번째 콜백
            new PreparedStatementCreator() {
                public PreparedStatement createPreparedStatement(connection c) throws SQLException {
                    return c.prepareStatement("select count(*) from users");
                }
            },
            // 두번째 콜백
            new ResultSetExtractor<Integer>() {
                public Integer extractData(ResultSet rs) throws SQLException, DataAccessException {
                    rs.next();
                    return rs.getInt(1);
                }
            }
        );
    }
}

한 가지 짚고 넘어갈 것이 여기서 ResultSetExtractor의 extractData()는 제네릭스 타입이라는 점이다. ResultSet의 결과가 다양한 타입을 가질 것을 생각하면 제네릭스 타입인 것이 당연해 보인다.

앞에서 PreparedStatement을 만들어주는 콜백을 템플릿에 넣고 재활용할 수 있도록 executeQuery()를 만들었던 것처럼 마찬가지로 ResultSetExtractor 콜백까지 템플릿에 넣어서 재활용할 수 있다. JdbcTemplate의 queryForObject()를 사용하면 아래와 같이 된다.

public class UserDao {
    public int getCount() {
        // queryForObject(String sql, class<T> requiredType)
        return this.jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
    }
}

- queryForInt()는 deprecated됨. 링크

한 개의 오브젝트를 위한 queryForObject()

이번에는 get() 메소드로 하나의 오브젝트를 받아오는 경우를 생각해보자. 이 때도 똑같이 queryForObject()를 사용하면 되는데, 이번에는 ResultSetExtractor 콜백 대신 RowMapper 콜백을 사용하는데, RowMapper 콜백은 ResultSet의 row 하나를 매핑하여 반환한다. PreparedStatement를 만들기 위해 바인딩해야할 값들이 있다면 아래와 같이 Object 타입 배열을 사용한다.

public class UserDao {
    public User get() {
    	// queryForObject(String sql, RowMapper<T> rowMapper, @Nullable Object... args)
        return this.jdbcTemplate.queryForObject("select * from users where id = ?",
            new RowMapper<User>() {
                public User mapRow(ResultSet rs, int rowNum) {
                    User user = new User();
                    user.setId(rs.getString("id");
                    user.setName(rs.getString("name");
                    return user;
                }
            }, new Object[] {id}
        );
    }
}

만약 여기서 조회 결과가 없다면 EmptyReulstDataAccessException을 던진다.

오브젝트 리스트를 위한 query()

만약 row 한 개 이상의 값을 가져올 때는 query()를 사용하는데 이때 결과는 List<T>로 반환한다.

public class UserDao {
    public List<User> getAll() {
    	// query(String sql, RowMapper<T> rowMapper, @Nullable Object... args)
        return this.jdbcTemplate.query("select * from users order by id",
            new RowMapper<User>() {
                public User mapRow(ResultSet rs, int rowNum) {
                    User user = new User();
                    user.setId(rs.getString("id");
                    user.setName(rs.getString("name");
                    return user;
                }
            }
        );
    }
}

이 때는 SQL에서 조회되는 행의 개수만큼 RowMapper 콜백이 호출되서 각 행의 내용이 User 오브젝트에 매핑되어 List<User> 형태로 반환된다.

재사용 가능한 콜백의 분리

위에서 RowMapper 콜백을 사용할 때에도 보면 매번 코드가 중복되어 사용되는데 이 때도 앞에서 해왔던 것과 똑같이 코드를 분리시켜 재활용할 수 있다. 그러면 JdbcTemplate을 적용한 UserDao 코드는 최종적으로 아래와 같이 수정된다.

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

    private RowMapper<User> userMapper = new RowMapper<User>() {
        public User mapRow(ResultSet rs, int rowNum) {
            User user = new User();
            user.setId(rs.getString("id");
            user.setName(rs.getString("name");
            return user;
        }
    }
    
    public User get(String id) {
        return this.jdbcTemplate.queryForObject("select * from users where id = ?", this.userMapper, new Object[] {id});
    }
    public List<User> getAll() {
        return this.jdbcTemplate.query("select * from users order by id", this.userMapper);
    }
    public int getCount() {
        return this.jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
    }
    public void deleteAll() {
        this.jdbcTemplate.update("delete from users");
    }
}

주요 포인트를 정리하면.. UserDao는 User 정보와 DB에서 조회하는 쿼리문에 대해서만 관심을 갖는다. Dao와 DB 테이블 사이에서 정보를 어떻게 주고 받는지, DB 조회를 위한 SQL 쿼리문에 대해서만 관여를 하고 있다. 그 외에 DB와의 연결, 리소스 반납, JDBC API의 구체적인 사용 및 템플릿콜백 구현 등에 관해서는 JdbcTemplate이 모두 담당하고 있다.

여기서 UserDao의 응집도를 좀 더 높이려 한다면.. UserMapper를 빈으로 등록하고 설정에서 User 테이블의 필드 이름 및 매핑 정보를 넣어주고, 쿼리문도 하드 코딩이 아니라 리소스에서 읽어오는 방식으로 변경할 수 있다.


"개인적으로 공부하면서 정리한 자료입니다. 오타와 잘못된 내용이 있을 수 있습니다."

profile
개발은 즐겁게🎶

0개의 댓글