본문 바로가기
Spring

[Design Pattern] 템플릿/콜백 패턴(Template/Callback Pattern)

by 덩라 2023. 12. 8.

0. 분리가 어려운 반복코드

개발을 하다보면, "반복되는 부분이 눈에 보이는데, 쉽사리 분리하지 못하는 경우" 가 종종 발생하곤 합니다.

대표적으로, Java 의 try / catch / finally 가 있죠.

아래 코드는 DataSource 를 활용해 데이터를 처리하는 DAO 코드를 작성한 예시 입니다.

@Repository
public class UserDao {
    public DataSource dataSource;
    
    public UserDao(DataSource dataSource) {
        this.dataSource = dataSource;
    }
	
    public void insert(User user) {
        Connection c = null;
        PreparedStatement ps = null;

        try {
            c = dataSource.getConnection();
            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());

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

위 코드는 Spring 기반으로 작성된 User 정보를 DB 에 Insert 하는 코드입니다.

 

dataSource 를 통해 Connection 을 가져오고, 가져온 Connection을 통해 PreparedStatement 를 생성하여 DB 에 특정 쿼리를 execute 하도록 하는 기본적인 로직입니다.

 

DB에 실제로 접속을 시도하기 때문에, 메서드가 종료되는 시점에 DB에 접속하는데 사용됐던 자원은 close() 를 통해 반드시 메모리에서 제거해줘야 합니다.(이를 개발자들은 "자원을 반납한다." 라고 표현합니다.)

 

여기서 주목해볼 부분은, try/catch/finally 부분입니다.

 

만약 위 코드에서, 특정 User 정보를 삭제하는 로직이 추가된다면, 아래처럼 코드 양이 늘어나게 될 것 입니다.

@Repository
public class UserDao {
    public DataSource dataSource;
    
    public UserDao(DataSource dataSource) {
        this.dataSource = dataSource;
    }
	
    public void insert(User user) {
        Connection c = null;
        PreparedStatement ps = null;

        try {
            c = dataSource.getConnection();
            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());

            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) {}
            }
        }
    }
    
    public void delete(String userId) {
        Connection c = null;
        PreparedStatement ps = null;

        try {
            c = dataSource.getConnection();
            ps = c.prepareStatement("delete from users where id=?");
            ps.setString(1, userId);

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

쿼리가 바뀜에 따라, preparedStatement 를 통해 쿼리에 전달할 파라미터가 변했습니다.

하지만, 여전히 try/catch/finally 는 반드시 필요한 부분이기 때문에 다시 한 번 작성되었음을 알 수 있습니다.

 

메서드의 특정 부분이 중복된다면, 같은 클래스 내 메서드로 추출하던가 등의 방식으로 코드의 중복을 제거 할 수 있겠지만, 위와 같이 메서드 흐름의 앞/뒤 에 동일한 코드가 필요하고, 중간 로직이 바뀌는 구조에서 중복을 제거하기란 쉽지 않습니다.

 

위와 같은 문제를 해결하기 위해 많은 개발자들이 고민 끝에 여러 방식을 채택하였습니다.

오늘은 그 중, Spring 에서 대표적으로 자주 사용된 탬플릿/콜백 패턴에 대해 알아보려 합니다.

 

1. 탬플릿/콜백 패턴 이란?

템플릿/콜백 패턴은 개발 진영에서 많이 사용되는 디자인 패턴 중 하나 입니다.

디자인 패턴 이란, 프로그램을 개발함에 있어 반복되는 문제를 해결하기 위해 정형화한 규칙으로, 코드를 작성하는데 있어 반복을 줄이면서, 재사용성을 늘리는데 목적을 둔 개발기법을 의미합니다.

 

선배 개발자 분들 께서 많은 고민 끝에 여러 디자인 패턴을 고안해내셨고, 그 중 하나가 바로 템플릿/콜백 패턴 입니다.

템플릿/콜백 패턴은 동일한 작업을 수행하는 템플릿을 중심으로, 클라이언트가 명령한 작업을 실행하는 패턴입니다.

 

즉, 전체적으로 변하지 않는 고정된 로직을 담고 있는 템플릿그 템플릿 사이에서 실행되어야 하는 중간 로직을 분리해놓은 패턴이라고 볼 수 있겠습니다.

 

위에서 예시를 들었던 코드 중 insert 메서드를 예를 들어보겠습니다.

insert 메서드의 흐름을 그림으로 표현한다면 아래와 같이 표현될 수 있습니다.

 

이 중에서 2~3 번 과정의 경우는 어떤 쿼리를 DB에 전달하느냐에 따라 변경될 가능성이 있습니다.

예를 들면, insert 의 경우 저장할 데이터를 파라미터로 바인딩하고, delete 의 경우 삭제할 데이터의 pk 만 파라미터로 바인딩하면 됩니다.

그 외, 1 / 4~5 번의 과정은 2~3번이 어떤 쿼리를 DB에 전달하느냐에 관계 없이 동일하게 수행되는 부분입니다.

 

템플릿/콜백 패턴은 1 / 4~5 번과 같이 동일하게 수행되는 부분을 템플릿으로 분리하고, 2~3 번 같이 변경되는 부분을 콜백으로 나누어 클라이언트가 적절하게 조합하게 하는 방식을 사용합니다.

위 설명을 그림으로 표현하면, 아래와 같이 표현되게 됩니다.

 

UserDao 하나의 클래스 안에서 모든 작업을 수행했던 것과 달리, Template 역할과 Callback 역할을 수행할 두 개의 클래스로 나누고, Client 가 수행하고자 하는 Callback 을 생성하고, Template 에 생성한 Callback 을 전달하는 방식으로 구현하게 됩니다.

 

2. 템플릿/콜백 패턴 구현하기

앞서 설명한 템플릿/콜백 패턴을 실제로 구현해보도록 하겠습니다.

 

먼저, 기존 UserDao 에 전달할 Callback Interface 를 아래와 같이 정의합니다.

public interface Callback {
    PreparedStatement createPreparedStatement(Connection connection);
}

 

Insert 와 Delete 를 처리할 Callback Interface 의 구현체는 아래와 같이 정의합니다.

public class InsertCallback implements Callback{
    private final User user;

    public InsertCallback(User user) {
        this.user = user;
    }

    @Override
    public PreparedStatement createPreparedStatement(Connection connection) throws SQLException {
        PreparedStatement ps = connection.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 class DeleteCallback implements Callback {
    private final String userId;

    public DeleteCallback(String userId) {
        this.userId = userId;
    }

    @Override
    public PreparedStatement createPreparedStatement(Connection connection) throws SQLException {
        PreparedStatement ps = connection.prepareStatement("delete from users where id=?");
        ps.setString(1, userId);

        return ps;
    }
}

 

Callback 을 구현한 InsertCallback 과 DeleteCallback 은 각자 다른 쿼리를 실행하기 때문에, 필요한 파라미터도 다릅니다.

그런 차이는 필드변수를 활용해 필요한 파라미터를 구현체가 가지게끔 처리합니다.

 

마지막으로, Callback 을 사용해 공통로직을 처리하는 Template 을 아래와 같이 구현합니다.

public class CustomJDBCTemplate {
    private DataSource dataSource;

    public CustomJDBCTemplate(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void workWithCallback(Callback callback) throws SQLException {
        Connection c = null;
        PreparedStatement ps = null;

        try {
            c = dataSource.getConnection();

            ps = callback.createPreparedStatement(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) {}
            }
        }
    }
}

 

위에 구현된 Template 과 Callback을 활용하는 경우, 맨 앞에서 구현한 UserDao 가 아래처럼 변경됩니다.

public class UserDao {
    private final CustomJDBCTemplate jdbcTemplate;

    public UserDao(CustomJDBCTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
    
    public void addUser(User user) throws SQLException {
        jdbcTemplate.workWithCallback(new InsertCallback(user));
    }
    
    public void deleteUser(String userId) throws SQLException {
        jdbcTemplate.workWithCallback(new DeleteCallback(userId));
    }
}

 

맨 앞에서 지적한 try/catch/finally 가 메서드마다 반복되는 문제는 어느정도 해결된 형태입니다.

 

하지만, 위 형태는 "실제로 어떤 쿼리가 발생하는지, 어떤 파라미터가 바인딩되는지" 에 대해서는 알 수 없다는 문제점이 있습니다. 

물론, Callback의 구현체(InsertCallback, DeleteCallback)에서 직접 확인할 수 있겠지만, UserDao 가 InsertCallback 과 DeleteCallback 이라는 구현체에 의존하는 형태이므로, 개선의 여지가 어느정도 있어보입니다.

구현체를 제거하여 익명클래스 형태로 변경한다면, 아래처럼 변경할 수 있습니다.

public class UserDao {
    private final CustomJDBCTemplate jdbcTemplate;

    public UserDao(CustomJDBCTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void addUser(final User user) throws SQLException {
        jdbcTemplate.workWithCallback(connection -> {
            PreparedStatement ps = connection.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 deleteUser(final String userId) throws SQLException {
        jdbcTemplate.workWithCallback(connection -> {
            PreparedStatement ps = connection.prepareStatement("delete from users where id=?");
            ps.setString(1, userId);

            return ps;
        });
    }
}

 

위 형태로 보아하니, 그리 썩 개선된 것 처럼 보이지는 않습니다.

그래도 UserDao 에서 어떠한 쿼리가 어떠한 데이터를 가지고 DB에 전달되는지는 한 번에 파악할 수 있게 됐습니다.

 

3. 람다식 감추기

UserDao 에서 보여지는 람다식(Connection -> {})은 들여쓰기나 메서드의 depth 측면에서 가독성이 다소 떨어지는 단점을 가집니다.

자세히 뜯어보면, addUser 와 deleteUser 에 사용되는 람다식은 서로 다르게 보이지만, 하는 일은 똑같다는 것을 알 수 있습니다.

  1. 전달받은 Connection 과 query 를 통해 PreparedStatement 를 생성
  2. PreparedStatement 에 파라미터 바인딩
  3. PreparedStatement return

위 3단계 과정이 addUser 와 deleteUser 가 동일하게 수행되게 됩니다.

위 과정에서 변하는 것1) 실행할 쿼리 문자열2) 바인딩할 파라미터 입니다.

 

위 2가지의 변경 점을 고려하여 위 로직을 하나의 메서드(makePreparedStatement)로 묶어보면 아래와 같이 묶일 수 있습니다.

public class UserDao {
    private final CustomJDBCTemplate jdbcTemplate;

    public UserDao(CustomJDBCTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void addUser(final User user) throws SQLException {
        jdbcTemplate.workWithCallback(connection -> makePreparedStatement(connection, "insert into users(id, name, password) values(?, ?, ?)", user.getId(), user.getName(), user.getPassword()));
    }

    public void deleteUser(final String userId) throws SQLException {
        jdbcTemplate.workWithCallback(connection -> makePreparedStatement(connection, "delete from users where userId=?", userId));
    }
    
    private PreparedStatement makePreparedStatement(Connection connection, String query, Object[] args) throws SQLException {
        PreparedStatement ps = connection.prepareStatement(query);
        int parameterLength = args.length;
        for (int i = 0; i < parameterLength; i++) {
            ps.setString(i+1, args[i].toString());
        }

        return ps;
    }
}

람다에서 가져오는 connection 과 실행할 쿼리 문자열, 그리고 파라미터로 바인딩할 정보들을 배열 형태로 매핑하는 공통 메서드로 추출한 결과입니다.

 

사실 UserDao 의 입장에선 preparedStatement 가 어떤 과정을 통해 만들어지는지는 관심이 없습니다. 

실행할 쿼리 와 그 쿼리에 사용될 파라미터만으로 원하는 결과를 수행하기만 하면 그만이죠.

그렇게 될 수 있다면, connection 객체도 의존할 필요가 없어지는 것입니다.

 

마지막으로, CustomJDBCTemplate 의 workWithCallback 이라는 메서드를 CustomJDBCTemplate 안으로 숨기고, 

UserDao 에서 쿼리 문자열과 파라미터 만 CustomJDBCTemplate 에 전달하는 형태로 메서드를 수정하면 아래와 같은 결과가 나올 수 있습니다.

public class UserDao {
    private final CustomJDBCTemplate jdbcTemplate;

    public UserDao(CustomJDBCTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void addUser(final User user) throws SQLException {
        jdbcTemplate.excuteQuery(
            "insert into users(id, name, password) values(?, ?, ?)", 
            user.getId(), user.getName(), user.getPassword()
        );
    }

    public void deleteUser(final String userId) throws SQLException {
        jdbcTemplate.excuteQuery(
            "delete from users where userId=?", 
            userId
        );
    }
}
public class CustomJDBCTemplate {
    private DataSource dataSource;

    public CustomJDBCTemplate(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void executeQuery(final String query, final Object... args) throws SQLException {
        workWithCallback(connection -> makePreparedStatement(connection, query, args));
    }
    
    private void workWithCallback(Callback callback) throws SQLException {
        Connection c = null;
        PreparedStatement ps = null;

        try {
            c = dataSource.getConnection();

            ps = callback.createPreparedStatement(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) {}
            }
        }
    }

    private PreparedStatement makePreparedStatement(Connection connection, String query, Object[] args)
            throws SQLException {
        PreparedStatement ps = connection.prepareStatement(query);
        int parameterLength = args.length;
        for (int i = 0; i < parameterLength; i++) {
            ps.setString(i+1, args[i].toString());
        }

        return ps;
    }
}

 

이제 UserDao 는 CustomJDBCTemplate 객체에게 실행할 쿼리 문자열, 바인딩할 파라미터 정보만 넘겨주면

DB 커낵션을 맺고, 파라미터를 바인딩하고, 쿼리를 실행하고, 결과를 가져오는 모든 행위는 CustomJDBCTemplate 가 알아서 처리하게 끔 만들어졌습니다.

 

위와 같은 CustomJDBCTemplate 같은 탬플릿을 통해, 개발자가 새로운 Dao 를 개발하기가 훨씬 수월해진 것입니다.

 

4. 스프링의 JdbcTemplate

위 과정을 통해 우린 템플릿/콜백 패턴이 개발자에게 가져다주는 이점을 여러 차례 확인해 볼 수 있었습니다.

스프링에서도 위와 같은 문제를 해결해준 JdbcTemplate 이라는 객체를 제공합니다.

https://docs.spring.io/spring-framework/reference/data-access/jdbc/core.html#jdbc-JdbcTemplate

 

Using the JDBC Core Classes to Control Basic JDBC Processing and Error Handling :: Spring Framework

Some query methods return a single value. To retrieve a count or a specific value from one row, use queryForObject(..). The latter converts the returned JDBC Type to the Java class that is passed in as an argument. If the type conversion is invalid, an Inv

docs.spring.io

https://docs.spring.io/spring-framework/docs/6.1.3/javadoc-api/org/springframework/jdbc/core/JdbcTemplate.html

 

JdbcTemplate (Spring Framework 6.1.3 API)

Execute a query for a result object, given static SQL. Uses a JDBC Statement, not a PreparedStatement. If you want to execute a static query with a PreparedStatement, use the overloaded JdbcOperations.queryForObject(String, Class, Object...) method with nu

docs.spring.io

 

간단하게 예제로, 위에서 개발한 UserDao 를 스프링이 제공하는 JdbcTemplate 을 사용해보면 아래와 같습니다.

public class UserDao {
    private JdbcTemplate jdbcTemplate;

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

    public void addUser(User user) {
        jdbcTemplate.update("insert into users(id, name, password) values(?, ?, ?)",
                user.getId(), user.getName(), user.getPassword());
    }

    public void deleteUser(String userId) {
        jdbcTemplate.update("delete from users where userId=?", userId);
    }
}

update() 메서드를 사용한다는 것 외에는 우리가 앞에서 개발해본 CustomJDBCTemplate 가 아주 유사합니다.

스프링에서 제공해주는 JdbcTemplate 이 템플릿/콜백 패턴을 활용해서 만들어진 대표적인 예라고 볼 수 있겠습니다.

(이름에서부터 Template 이..?)

 


참고 및 출처.

토비의 스프링 3.1 1편

https://product.kyobobook.co.kr/detail/S000000935360

 

토비의 스프링 3.1 세트 | 이일민 - 교보문고

토비의 스프링 3.1 세트 | 애플리케이션 아키텍처 설계부터 프레임워크 제작까지 다룬 스프링 가이드북!『토비의 스프링 3.1 세트』는 스프링을 처음 접하거나 스프링을 경험했지만 스프링이 어

product.kyobobook.co.kr

 

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard

 

스프링 핵심 원리 - 고급편 강의 - 인프런

스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기 📢 수강

www.inflearn.com

 

댓글