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 에 사용되는 람다식은 서로 다르게 보이지만, 하는 일은 똑같다는 것을 알 수 있습니다.
- 전달받은 Connection 과 query 를 통해 PreparedStatement 를 생성
- PreparedStatement 에 파라미터 바인딩
- 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
간단하게 예제로, 위에서 개발한 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
'Spring' 카테고리의 다른 글
[Spring] Spring AOP - 관심사 분리(부가기능과 핵심기능) (0) | 2024.02.26 |
---|---|
[Design Pattern] 프록시 패턴 & 데코레이터 패턴 (0) | 2024.02.15 |
[Spring Rest Docs] 테스트 코드를 통한 API 문서 만들기 (0) | 2023.10.20 |
[ArgumentResolver] 로그인 여부를 ArgumentResolver로 처리하기 (0) | 2023.04.25 |
스프링의 객체지향 (1) | 2023.03.06 |
댓글