-
Notifications
You must be signed in to change notification settings - Fork 1
3장 템플릿
개방 폐쇄 원칙을 다시한번 생각해 보자. 이 원칙은 코드에서 어떤 부분은 변경을 통해 그 기능이 다양해지고 확장하려는 성질이 있고, 어떤 부분은 고정되어 있고 변하지 않으려는 성질이 있음을 말해준다. 변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어주는것이 바로 이 개방 폐쇄 원칙이다.
템플릿이란 이렇게 바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며, 일정한 패턴으로 유지되는 특성을 가진 부분으 자유롭게 변경하는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다.
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 {
try {
ps.close();
} catch (SQLException e) {
}
try {
c.close();
} catch (SQLException e) {
}
}
}
2장에서 작성하였던 데이터 삭제처리에서 에러가 발생할 경우 connection이 닫히지 않는 경우를 대비하여 소스를 개선하였다.
하지만 복한한 try/catch/finally 블록이 2중으로 중첩까지 되어 나오는데다, 모든 메서드들마다 반복이 될 수 밖에 없다. 이런 코드는 계속폭탄이 될 가능성을 지니고 있다. 누군가 DAO로직을 수정하려고 했을 때 복잡한 try/catch/finally 블록 안에서 필요한 부분을 찾아서 수정해야 하고, 언젠가 꼭 필요한 부분을 잘못 삭제해버리면 역시 같은 문제가 반복된다. 언제 터질지도 모르는 폭탄과 같은 코드가 되는 것이다.
이런 코드를 효과적으로 다룰 수 있는 방법은 없을까? 개발자라면 당연히 이런 의문으로 가져야 한다. 문제의 핵심은 변하지 않는 그러나 많은곳엣 중복되는 코드와 로직에 따라 자꾸 확장되고 자주 변하는 코드를 잘 분리해내는 작업이다.
먼저 메스드 추출을 사용해 코드를 분리해내는 방법을 고민해 볼 수 있다.
public void deleteAll() throws SQLException {
...
try {
c = dataSource.getConnection();
ps = makePreparedStatement(c);
ps.executeUpdate();
} catch (SQLException e) {
...
}
}
private PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("delete from users");
return ps;
}
일반적인 메서드 추출 리팩터링을 적용하는 경우에는 분리시킨 메서드를 다른곳에서 재사용할 수 있어야 하는데, 이건 반대로 분리시키고 남은 메서드가 재사용이 필요한 부분이고, 분리된 메서드는 DAO로직마다 새롭게 만들어서 확장돼야 하는 부분이기 때문에 문제가 된다.
템플릿 메서드 패턴은 상속을 통해 기능을 확장해서 사용하는 부분이다. 변하지 않는 부분은 슈퍼클래스에 두고, 변하는 부분은 추상메서드로 정의해 두어서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것이다.
템플릿 메서드 패턴을 사용하면 아래와 같이 코드를 변경할수 있다.
public abstract class UserDao {
abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
}
public class UserDaoDeleteAll extends UserDao{
@Override
protected PreparedStatement makeStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("DELETE FROM users");
return ps;
}
}
하지만 이런 형식으로 만들 경우 각 하나의 기능마다 하나의 서브클래스를 만들어주어야 한다. UserDao와 동일한 기능을 하는 코드를 위해 총 4가지의 클래스를 만들어야 된다는 뜻이다. 또한 확장구조가 이미 클래스를 설계하는 시점에서 고정되어 버린다는 점 역시 문제이다. 변하지 않는 코드를 가진 UserDao의 JDBC try/catch/finally 블록과 변하는 PreparedStatement를 담고있는 서브클래스들이 이미 클래스 레벨에서 컴파일 시점에 이미 그 관계가 결정되어 있다. 따라서 그 관계에 대한 유연성이 떨어져 버린다. 상속을 통해 확장을 꾀하는 템플리스 메서드 패턴의 단범이 고스란히 드러난다.
개발폐쇄원칙을 잘 지키는 구조이면서 템플릿 메서드 패턴보다 유연하고 확장성이 뛰어난 것이, 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략패턴이다. 전략패턴은 OCP관점에서 보면 홪강에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식이다.
public interface StatementStrategy {
PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}
public class DeleteAllStatement implements StatementStrategy{
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("delete from users");
return ps;
}
}
public void deleteAll() throws SQLException {
...
try {
c = dataSource.getConnection();
StatementStrategy strategy = new DeleteAllStatement();
ps = strategy.makePreparedStatement(c);
ps.executeUpdate();
} catch (SQLException e) {
...
}
}
전략패턴은 필요에 따라 컨텍스트는 그대로 유지하면서 전략을 바꿔쓸 수 있다는 것인데, 이렇게 컨텍스트 안에서 이미 구체적인 전략클래스은 DeleteAllStatement를 사용하도록 고정되어 있다면 어딘가 이상한다. 컨텍스트가 StatementStrategy 인터페이스뿐 아니라 특정 구현 클래스인 DeleteAllSatement를 직접 알고 있다는 건, 전략패턴에도, OCP에서도 잘 들어맞는다고 볼 수 없기 때문이다.
이 문제를 해결하기 위해 전략패턴의 실제적인 사용방법을 좀 더 살펴보자. 전략패턴에 따르면 Content가 어떤 전략을 사용하게 할 것인가는 Context를 사용하는 앞단의 Client가 결정하는 게 일반적이다. Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에 전달하는 것이다. Context는 전달받은 그 Strategy구현 클래스의 오브젝트를 사용한다.
컨텍스트에 해당하는 부분을 별도의 메서드로 다시 분리시켜보자.
private void jdbcContextStatementStrategy(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 {
try {
ps.close();
} catch (SQLException e) {
}
try {
c.close();
} catch (SQLException e) {
}
}
}
public void deleteAll() throws SQLException {
jdbcContextStatementStrategy(new DeleteAllStatement());
}
이제 구조로 볼 때 완벽한 전략패턱의 모습을 갖추었다. 비록 클라이언트와 컨텍스트는 클래스를 분리하진 않았지만, 의존관계와 책임으로 볼 떄 이상적인 클라이언트/컨텍스트 관계를 가지고 있다. 특히 클라이언트가 컨텍스가 사용할 전략을 정해서 전달한다는 면에서 DI구조라고 이해할 수도 있다. 아직까지는 이렇게 분리한 것에서 크게 장점이 보이지 않는다. 하지만 지금까지 해 온 관심사를 분리하고 유연한 확정관계를 유지하도록 만든 작업은 매중 중요하다.
스프링은 JDBC를 이용하는 DAO에서 사용할 수 있도록 준비된 다양한 템플릿과 콜백을 제공한다 거의 모든 종류의 JDBC코드에 사용가능한 템플릿과 콜백을 제공할 뿐만 아니라, 자주 사용되는 패턴을 가진 콜백은 다시 템플릿에 결합시켜서 간단한 메서드 호출만으로 사용이 가능하도록 만들어져 있기 때문에 템플릿/콜백 방식의 기술을 사용하고 있는지 모르고도 쓸 수 있을 정도로 편리하다.
public class UserDao {
private DataSource dataSource;
private JdbcTemplate jdbcTemplate;
public void setDataSource(DataSource dataSource){
this.jdbcTemplate.setDataSource(dataSource);
this.dataSource = dataSource;
}
public void deleteAll() {
//jdbcTemplate를 사용하면 아래와 같이 더욱 간략하게 SQL 문을 실행할 수 있다.
jdbcTemplate.update("delete all from users");
}
public User get(String id){
return jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id}, (resultSet, i) -> {
User user = new User();
user.setId(resultSet.getString("id"));
user.setName(resultSet.getString("name"));
user.setPassword(resultSet.getString("password"));
return user;
});
}
}
ps) queryForInt()는 Spring 3.2.2를 기준으로 하여 deprecated되었다. queryForObject를 사용하도록 권장되며 기존과 동일한 결과값을 표현하기 위해서 jdbcTempalte.queryForObject("SQL", Integer.class)
를 사용한다. queryForObject에서 같은 기능을 충분히 할 수 있으므로, 해당 메서드를 삭제하여 메서드의 모호함을 제거한듯 하다.
성공적인 테스트 결과를 보면 빨리 다음 기능을 넘어가고 싶겠지만, 너무 서두르는것은 좋지않다. 항상 꼼꼼하게 빠진것은 없는지 더 개선할 부분은 없는지 한번쯤 생각해보자.
네거티브 테스트라고 불리는 예외상황에 대한 테스트는 항상 빼먹기 쉽다. 때문에 테스트를 작성할때에는 항상 네거티브 테스트부타 만드는 습관을 들이는 것이 좋다. 의도적으로 예외적인 상황에 대한 테스트를 만드는 습관을 들이는 것이다. 이는 TDD의 기본인 쉽게 만들수 있는 테스트를 먼저 만드는것과도 일맥상통하는 이야기이다.