객체지향 설계의 핵심 원칙인 OCP (개방 폐쇄 원칙)은 코드에서 어떤 부분은 변경을 통해 그 기능이 다양해지고
확장하려는 성질이 있고, 어떤 부분은 고정되어 있고 변하지 않으려는 성질이 있음을 말합니다.
템플릿이란 코드에서 변하는 부분과 변하지 않는 부분을 구분하고 변하지 않는 부분을 독립시키는 방법입니다.
이 템플릿에 변하는 부분만 바꿔 끼우면 여러 곳에서 재사용이 가능하기에 객체지향적 이점을 잘 활용할 수 있습니다.
1. 다시 보는 초난감 DAO
UserDao의 deleteAll() 메소드 입니다.
public void deleteAll() throws SQLException {
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("delete from users");
ps.execute();
ps.close();
c.close();
}
만약 코드 중간에 에러가 난다면 ps.close() 와 c.close() 가 실행되지 않아 리소스가 반환되지 않는 상황이 발생할 수 있습니다.
그렇게 되면 DB 풀에 반환되지 않는 커넥션이 계속 쌓이게 되고 리소스가 모자란 오류를 낼 것입니다.
이 점을 방지하기 위해서 try/catch/finally 구문을 사용해서 리소스를 반환해 줘야합니다.
해당 구문을 적용하면 다음과 같은 코드가 생성됩니다.
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) {
}
}
}
}
여기서 null값 체크를 한 이유는 에러가 발생하는 시점에 따라서 Connection, PreparedStatement 객체가 생성되지 않을 수 있어 NullPointException이 발생하는 에러를 잡기 위함입니다.
2. 변하는 것과 변하지 않는 것
위의 코드는 에러는 모두 잡아주지만 try/catch/finally 블록이 2중으로 중첩되고 모든 메소드 마다 반복될 것입니다.
같은 코드가 반복되다 보면 실수로 코드가 누락이 되는 상황이 발생할 수 있고 반복되는 코드 중 한 부분을 변경하면
반복된 모든 곳을 수정해야 하는 번거로움이 발생합니다.
이런 수고를 덜고자 디자인 패턴을 적용해 보겠습니다.
템플릿 메소드 패턴
템플릿 메소드 패턴은 상속을 통해 기능을 확장해서 사용합니다.
변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메소드로 정의해둬서 서브클래스에서 오버라이드하여
새롭게 정의해서 쓰는 방식입니다.
먼저 위의 코드에서 변하지 않는 부분과 변하는 부분을 나눠 메소드로 추출해보겠습니다.
변하지 않는 부분
public void deleteAll() throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = makeStatement(); // 이 부분이 변하는 부분입니다.
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) {
}
}
}
}
makeStatement를 추상메소드로 가지고 있는 UserDao를 상속받아 UserDaoDeleteAll 을 구현해보겠습니다.
public class UserDaoDeleteAll extends UserDao {
protected PreparedStatement makeStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("delete from users");
return ps;
}
}
템플릿 메소드 패턴을 통해서 OCP 원칙을 지키는 구조를 만들어낼 수 있습니다.
하지만 이 패턴은 DAO로직마다 상속을 통해 새로운 클래스를 만들어내야 한다는 단점이 있습니다.
그리고 확장 구조가 클래스를 설계하는 시점에서 고정되어 버려서 유연성이 떨어지는 점도 있습니다.
해당 방법을 보완하기 위해서 전략 패턴을 적용해 봅시다.
전략 패턴
OCP 원칙을 잘 지키는 구조이면서 템플릿 메소드보 패턴보다 유연하고 확장성이 좋습니다.
오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 패턴입니다.
앞으로 나올 용어를 정리해 보겠습니다.
Context : 메소드의 문맥을 뜻합니다. 메소드가 정의 된 순서대로 동작하는 흐름을 뜻합니다.
Strategy : 메소드에 적용될 전략을 뜻합니다. 메소드에서 변하는 부분을 뜻합니다.
전략 패턴의 구조는 다음과 같습니다.
이러한 전략 패턴 구조를 따라서 인터페이스를 만들어두고 인터페이스의 메소드를 통해 PreparedStatement 생성 전략을 호출 해주면 됩니다.
StatementStrategy 인터페이스
public interface StatementStrategy {
PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}
StatementStrategy 구현 클래스
public class DeleteAllStatement implements StatementStrategy {
public PreparedStatement makeStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("delete from users");
return ps;
}
}
해당 인터페이스를 받아 DB 커넥션을 하기 위한 메소드로 수정해 보겠습니다.
이제 deleteAll 기능만 하지 않기 때문에 메소드 명을 jdbcContextWithStatementStarategy로 변경 하겠습니다.
public void jdbcContextWithStatementStarategy(StatementStrategy stmt) throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
c = 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) {
}
}
}
}
이제 deleteAll() 메소드는 jdbcContextWithStatementStarategy()를 호출하는 클라이언트가 됩니다.
public void deleteAll() throws SQLException {
StatementStrategy st = new DeleteAllStatement();
jdbcContextWithStatementStarategy(st);
}
3. JDBC 전략 패턴의 최적화
위의 전략 패턴을 사용하려면 StatementStrategy 인터페이스를 상속받은 클래스를 매번 만들어야 합니다.
이러한 수고를 덜고자 자바에서는 익명클래스를 지원합니다.
익명 클래스란 이름을 갖지 않는 클래스 입니다.
클래스의 선언과 오브젝트 생성이 결합된 형태이며 상속할 클래스나 인터페이스를 생성자 대신 사용해서
다음과 같은 구조로 사용합니다.
new 인터페이스 이름() { 클래스 본문 };
deleteAll() 메소드에 바로 적용해 보겠습니다.
public void deleteAll() throws SQLException {
jdbcContextWithStatementStarategy(
new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c)
throws SQLException {
return c.prepareStatement("delete from users");
}
}
);
}
StatementStrategy를 바로 넘겨줄 수 있기에 파라미터 안에 정의했습니다.
인터페이스를 생성하고 안에 구현할 메소드를 구현해주면 됩니다.
해당 클래스에는 이름을 정의하지 않기에 익명 클래스라 부릅니다.
Java8 이후에는 람다를 지원하기에 람다를 사용하여 사용할수 있습니다.
4. 컨텍스트와 DI
위의 jdbcContextWithStatementStarategy()를 UserDao 에서만이 아닌 여러 DAO에서 사용할 수 있게 클래스로 분리 해 보겠습니다.
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 = 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) {
}
}
}
}
}
클래스로 따로 분리했으므로 메소드 이름을 workWithStatementStrategy()로 변경했습니다.
이제 UserDao에서는 이 클래스를 빈으로 주입받아 사용할 수 있게 됩니다.
public class UserDao {
private JdbcContext jdbcContext;
public void setJdbcContext(JdbcContext jdbcContext) {
this.jdbcContext = jdbcContext;
}
public void add(final User user) throws SQLException {
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
@Override
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;
}
});
}
public void deleteAll() throws SQLException {
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement("delete from users");
}
});
}
}
이때 UserDao에서는 JdbcContext를 DI받았습니다. 기존에는 인터페이스를 사용해서 주입을 받았지만 이때는 클래스 레벨에서 바로 주입을 받았습니다.
기존 DI는 객체 사이에 인터페이스를 둬서 두 의존 결합도를 느슨하게 하는 역할을 했습니다.
하지만 이 경우는 클래스끼리 바로 주입이 되는데 올바른 DI방식을 따른 것일까요?
먼저 DI 개념을 넓게 보자면 객체의 생성과 관계설정에 대한 제어권한을 오브젝트에서 제거하고 외부로 위임했다는 IoC라는 개념을 포괄합니다.
이런 의미에서 JdbcContext를 스프링을 이용해서 UserDao 객체에 주입했다는 건 DI의 기본을 따른다고 볼 수 있습니다.
인터페이스를 사용하지 않았지만 이 상황에서 DI를 사용해야하는 이유를 살펴봅시다.
1. JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되기 때문입니다.
JdbcContext는 그 자체로 상태를 가지고 있지않고 여러 Dao에서 공유되어 사용되기 때문에 싱글톤으로 사용되는 것이 이상적입니다.
2. JdbcContext가 DI를 통해 다른 빈에 의존하기 있기 때문입니다.
IoC 대상이 되어야 DI에 참여할 수 있기에 JdbcContext도 물론 빈으로 등록되어야 합니다.
하지만 이렇게 인터페이스를 사용하지않고 바로 DI한다는 건 문제가 발생할 수 있습니다.
인터페이스가 없다는건 두 객체 사이에 매우 긴밀한 관계를 가지고 있다는 걸 의미합니다.
만약 UserDao가 JDBC방식 대신 JPA나 하이버네이트 같은 ORM을 사용하게 된다면 JdbcContext가 통째로 바뀐다는 위험을 가지고 있습니다.
스프링의 도움을 받지 않고 JdbcContext를 코드 내에서 수동으로 DI하는 방식도 존재합니다.
public class UserDao {
private JdbcContext jdbcContext;
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.jdbcContext = new JdbcContext();
this.jdbcContext.setDataSource(dataSource);
this.dataSource = dataSource;
}
public void add(final User user) throws SQLException {
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
@Override
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;
}
});
}
public void deleteAll() throws SQLException {
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement("delete from users");
}
});
}
}
DataSource를 주입받는 과정에서 JdbcContext를 생성하는 방식입니다.
코드를 자세히 보겠습니다.
public void setDataSource(DataSource dataSource) {
this.jdbcContext = new JdbcContext();
this.jdbcContext.setDataSource(dataSource);
this.dataSource = dataSource;
}
이 방식은 수정자 메소드(Setter)를 사용해서 빈을 주입받는 방식입니다.
인터페이스를 두지 않아도 되고 수정자 메소드에서 원하는 오브젝트에 대한 DI를 할 수 있는 장점이 있습니다.
하지만 권장하는 방식이 아니므로 넘어가겠습니다.
5. 템플릿과 콜백
위에서 처럼 전략 패턴의 기본 구조에 익명 내부 클래스를 사용한 방식을 스프링에서는 템플릿/콜백 패턴이라고 부릅니다.
템플릿 : 전략 패턴의 컨텍스트
콜백 : 실행을 위해서 다른 오브젝트의 메소드에 전달되는 오브젝트
자바에서는 메소드 차제를 파라미터로 넘길 수 없다고 책에서 설명하지만 자바8 이후 부터는 람다가 허용
템플릿/콜백 패턴 특징
- 콜백은 일반적으로 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스로 만들어진다.
- 매번 메소드 단위로 사용할 오브젝트를 새롭게 전달받는다.
편리한 콜백의 재활용
deleteAll() 메소드를 다시 한번 보겠습니다.
public void deleteAll() throws SQLException {
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement("delete from users");
}
});
}
위에서 반복되는 부분을 뽑아 내서 템플릿으로 만들고 콜백 함수만 남았습니다.
하지만 deleteAll() 메소드 뿐만 아니라 다른 여러 메소드에서 이 콜백 함수를 사용할것입니다.
다른 메소드에서 콜백을 사용할때 변하는 부분은
return c.prepareStatement("delete from users");
이 부분일 것입니다.
그럼 해당 부분을 제외한 나머지 부분을 뽑아내겠습니다.
public void deleteAll() throws SQLException {
executeSql("delete from users");
}
private void executeSql(final String query) throws SQLException {
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement(query);
}
});
}
executeSql() 메소드로 뽑아내고 query 부분만 deleteAll()에서 파라미터로 넘겨주게 됩니다.
이제 다른 메소드에서도 query만 변경해서 콜백 함수를 사용할 수 있게 되었습니다.
executeSql()를 UserDao에서 JdbcContext로 옮기면 다른 Dao에서도 사용할 수 있습니다.
템플릿/콜백의 응용
실제 코드에 패턴을 적용시키는 부분입니다.
과정이 궁금하신 분은 다음 포스트를 참고해주세요.
https://water-dog.tistory.com/11?category=948633
6. Spring의 JdbcTemplate
Spring에서는 JDBC 코드용 JdbcTemplate을 제공합니다.
DB 콜을 위해서 직접 만들 수 있지만 여러 기능들을 편리하게 사용할 수 있으므로 해당 객체의 기본적인 메소드에 대해서 알아봅겠습니다.
update()
update() 메소드는 기본적으로 sql문과 함께 파라미터로 쓸 객체들을 가변인자로 받습니다.
@Override
public int update(String sql, @Nullable Object... args) throws DataAccessException {
return update(sql, newArgPreparedStatementSetter(args));
}
여러 update() 메소드가 존재하지만 그 중 로컬 클래스를 사용한 메소드가 있어서 가져왔습니다.
@Override
public int update(final String sql) throws DataAccessException {
Assert.notNull(sql, "SQL must not be null");
if (logger.isDebugEnabled()) {
logger.debug("Executing SQL update [" + sql + "]");
}
/**
* Callback to execute the update statement.
*/
class UpdateStatementCallback implements StatementCallback<Integer>, SqlProvider {
@Override
public Integer doInStatement(Statement stmt) throws SQLException {
int rows = stmt.executeUpdate(sql);
if (logger.isTraceEnabled()) {
logger.trace("SQL update affected " + rows + " rows");
}
return rows;
}
@Override
public String getSql() {
return sql;
}
}
return updateCount(execute(new UpdateStatementCallback()));
}
메소드 레벨에서 클래스를 생성하고 StatementCallBack 인터페이스를 상속받아 doInStatement 메소드를 구현했습니다.
@Override
@Nullable
public <T> T execute(StatementCallback<T> action) throws DataAccessException {
Assert.notNull(action, "Callback object must not be null");
Connection con = DataSourceUtils.getConnection(obtainDataSource());
Statement stmt = null;
try {
stmt = con.createStatement();
applyStatementSettings(stmt);
T result = action.doInStatement(stmt);
handleWarnings(stmt);
return result;
}
catch (SQLException ex) {
// Release Connection early, to avoid potential connection pool deadlock
// in the case when the exception translator hasn't been initialized yet.
String sql = getSql(action);
JdbcUtils.closeStatement(stmt);
stmt = null;
DataSourceUtils.releaseConnection(con, getDataSource());
con = null;
throw translateException("StatementCallback", sql, ex);
}
finally {
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, getDataSource());
}
}
반환값으로 호출한 excute()의 내부 모습입니다.
try/catch/finally문을 사용하고 타입 파라미터를 사용해서 타입 값을 동적으로 바인딩해 사용하고 있습니다.
이러한 모습은 우리가 앞서 작성한 UserDao의 JdbcTemplate과 유사함을 보입니다.
queryForObject()
쿼리, 바인딩 파라미터, 결과 객체를 원하는 객체로 변환하는 매퍼를 받는 메소드입니다.
해당 메소드는 결과로 하나의 메소드만 반환합니다.
@Override
@Nullable
public <T> T queryForObject(String sql, Object[] args, int[] argTypes, RowMapper<T> rowMapper)
throws DataAccessException {
List<T> results = query(sql, args, argTypes, new RowMapperResultSetExtractor<>(rowMapper, 1));
return DataAccessUtils.nullableSingleResult(results);
}
메소드 내부에서 query메소드를 사용한다.
query()
@Nullable
public <T> T query(
PreparedStatementCreator psc, @Nullable final PreparedStatementSetter pss, final ResultSetExtractor<T> rse)
throws DataAccessException {
Assert.notNull(rse, "ResultSetExtractor must not be null");
logger.debug("Executing prepared SQL query");
return execute(psc, new PreparedStatementCallback<T>() {
@Override
@Nullable
public T doInPreparedStatement(PreparedStatement ps) throws SQLException {
ResultSet rs = null;
try {
if (pss != null) {
pss.setValues(ps);
}
rs = ps.executeQuery();
return rse.extractData(rs);
}
finally {
JdbcUtils.closeResultSet(rs);
if (pss instanceof ParameterDisposer) {
((ParameterDisposer) pss).cleanupParameters();
}
}
}
});
}
내부적으로 excute() 메소드를 실행하고 익명 클래스를 사용한 모습을 볼 수 있습니다.
JdbcTemplate의 대표적인 메소드를 알아봤습니다. 우리는 Dao에서 여러 방법으로 DB에 콜을 날릴 수 있습니다.
하지만 해당 코드를 사용하는 개발자 입장에서는 내부적으로 어떤 방식을 사용했고 예외 상황 시 어떤 데이터가 오는지 알 길이 없습니다. 이런 경우를 대비해서 여러 네거티브 케이스들에 대한 테스트 코드를 작성해 두는 것이 좋습니다.
예를 들어 List를 결과로 받는 메소드인데 결과값이 없는 경우를 보겠습니다.
public void getAll(){
List<USer> users0 = dao.getAll();
assertThat(users0.size(), is(0));
}
예제와 위의 JdbcTemplate에서 볼 수 있듯이 메소드 내에서도 테스트 코드를 활용하는 모습을 볼 수 있습니다.
추가적으로 위에서 사용하는 RowMapper같은 경우 Dao 내부적으로 콜백 함수로 정의해 두면 코드의 중복을 없앨 수 있습니다.
public class UserDao {
@Autowired
JdbcTemplate jdbcTemplate;
private RowMapper<User> userMapper =
new RowMapper<User>() {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User();
... // user map code
return user;
}
};
...
이번 포스트는 토비의 스프링 3.1 템플릿에 대한 내용을 정리 해 보았습니다.
감사합니다.
'Spring' 카테고리의 다른 글
Toby's Spring - Chap 6 - AOP (0) | 2020.07.19 |
---|---|
Toby's Spring - Chap 4 - Exception (0) | 2020.06.29 |
Spring - Assert (0) | 2020.06.28 |
Toby's Spring - Chap 3 - Template/Callback practice (0) | 2020.06.23 |
Toby's Spring - Chap 2 - Test (0) | 2020.06.23 |