토비의 스프링 3장(템플릿)에서 거대하고 복잡한 하나의 메서드에서 변하는 것과 변하지 않는 것을 분리하며 객체지향의 핵심 원칙(개방폐쇄원칙)을 점진적으로 구현해 나가는 과정을 보여줍니다. 중복을 제거하고 재활성을 높였으며 변경에는 닫혀 있고 확장에는 열려있는 이 코드를 만들어 가는 과정을 나의 것으로 만들고 싶어 흐름을 다시 정리해 봅니다.
DB 테이블에 있는 모든 레코드를 삭제하기 위한 deleteAll이라는 메서드를 구현합니다. 처음에는 DB 연결, DB 쿼리 처리, 예외 처리, 자원 반환 등 모든 코드를 하나의 메서드 안에 구현합니다. 테스트 케이스를 통과하고 예외 처리, 자원 반환 기능도 완벽하게 동작합니다. 그러나 예외 처리와 자원 반환에 필요한 코드 때문에 실제 사용자의 요구를 구현한 코드가 잘 보이지 않습니다. 다른 기능 메서드를 구현할 때 복사 붙여넣기 해서 사용할 만한 중복 코드도 많이 보입니다.
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 { // 아래에 있는 연결에 대한 close()를 위해 꼭 필요함
ps.close();
} catch (SQLException ignored) {}
}
if (c != null) {
try {
c.close();
} catch (SQLException ignored) {}
}
}
}
메서드에서 변경이 발생하는 코드와 변하지 않는 코드를 분리하고 먼저 변경이 발생하는 코드 영역을 메서드로 추출해 봅니다. 메서드 추출 결과를 보면 추출한 코드(변하는 코드)가 아니라 변하는 코드를 감싸고 있는 변하지 않는 코드가 계속 중복될 수 있는 코드입니다. 그래서 변하는 코드를 감싸고 있는 변하지 않는 부분을 메서드로 추출해야 할 것 같습니다.
private PreparedStatement makeStatement(Connection c, String sql) throws SQLException {
return c.prepareStatement(sql);
}
public void deleteAll() throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = makeStatement(c, "delete from users");
ps.executeUpdate();
} catch (SQLException e) {
throw e;
} finally {
if (ps != null) {
try { // 아래에 있는 연결에 대한 close()를 위해 꼭 필요함
ps.close();
} catch (SQLException ignored) {}
}
if (c != null) {
try {
c.close();
} catch (SQLException ignored) {}
}
}
}
메서드에서 변하지 않으면서 일정한 구조를 가지는 코드 영역은 Context 메서드로 분리합니다. 그리고 변경되는 코드는 Strategy 클래스로 분리하여 Context 메서드 파라미터로 전달하고 Context 메서드 내에서 콜백해 주도록 합니다. 이때 Strategy 인터페이스를 정의하여 Context 메서드는 Strategy 구현 클래스의 인터페이만을 참조하게 합니다. 결과적으로 다양한 Strategy 구현 클래스를 (런타임에)필요에 따라 변경해서 사용하는 유연한 구조를 만듭니다.
@FunctionalInterface
public interface StatementStrategy {
PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}
public class DeleteAllStatement implements StatementStrategy {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement("delete from users");
}
}
public void jdbcContextWithStatementStrategy(StatementStrategy strategy) throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = strategy.makePreparedStatement(c);
ps.executeUpdate();
} catch (SQLException e) {
// 여기서 예외를 던질 필요가 없는지 체크하세요.
throw e;
} finally {
if (ps != null) {
try { // 아래에 있는 연결에 대한 close()를 위해 꼭 필요함
ps.close();
} catch (SQLException ignored) {}
}
if (c != null) {
try {
c.close();
} catch (SQLException ignored) {}
}
}
}
public void deleteAll() throws SQLException {
final StatementStrategy strategy = new DeleteAllStatement();
jdbcContextWithStatementStrategy(strategy);
}
이제 deleteAll 메서드를 개선하면서 분리한 Context 메서드와 Strategy 인터페이스를 활용해 add 메서드 역시 개선해 보겠습니다. add 메서드에는 deleteAll과 달리 SQL 쿼리에 user 정보라는 부가적인 정보가 필요합니다.
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());
ps.executeUpdate();
그래서 Add를 위한 Strategy 객체를 생성할 때는 생성자로 User 정보를 전달해 주어야 합니다.
public class AddStatement implements StatementStrategy {
private User user;
public AddStatement(User user) {
this.user = user;
}
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
final 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;
}
}
그래서 실제 add 메서드는 이런식으로 작성됩니다.
public void add(User user) throws ClassNotFoundException, SQLException {
final StatementStrategy strategy = new AddStatement(user);
jdbcContextWithStatementStrategy(strategy);
}
위에서 적용한 전략 패턴에는 여전히 생각해볼 문제가 있습니다.
위 문제를 해결하기 위해서 메서드 내에 전략 클래스를 정의(로컬 클래스로)함으로 기능 마다 추가되는 클래스의 개수를 줄일 수 있습니다.
public void add(User user) throws ClassNotFoundException, SQLException {
public class AddStatement implements StatementStrategy {
private User user;
public AddStatement(User user) {
this.user = user;
}
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
final 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;
}
}
final StatementStrategy strategy = new AddStatement();
jdbcContextWithStatementStrategy(strategy);
}
뿐만 아닙니다. 로컬 클래스는 메서드의 지역 변수를 직접 참조할 수 있습니다. 그래서 생성자를 통해 부가정보를 입력받고 멤버 변수로 저장하는 번거로운 코드를 작성할 필요가 없습니다. 아래와 같이 조금 더 코드가 심플해 집니다.
public void add(User user) throws ClassNotFoundException, SQLException {
public class AddStatement implements StatementStrategy {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
final 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;
}
}
final StatementStrategy strategy = new AddStatement();
jdbcContextWithStatementStrategy(strategy);
}
한 걸음 더 나아가 클래스에 이름을 부여하는 클래스 선언부를 제거하고 익명클래스를 생성함으로 코드를 더 단순화할 수 있습니다.
public void add(User user) throws ClassNotFoundException, SQLException {
jdbcContextWithStatementStrategy(new StatementStrategy(){
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
final 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;
}
});
}
책에 나오지 않지만 Lambda식을 적용하면 코드를 더 단순하게 만들 수 있습니다.
public void add(User user) throws ClassNotFoundException, SQLException {
jdbcContextWithStatementStrategy(connection -> {
final 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;
});
}
여기까지의 개선 과정을 통해 다음과 같은 장점을 얻을 수 있습니다.
위에서 메서드에서 변하지 않으면서 일정한 패턴을 가지는 코드는 Context 메서드로 분리했습니다. 그러나 Context 메서드는 일반적인 JDBC 서비스 기능을 담당함으로 애플리케이션 내 다른 클래스에서도 사용할 수 있습니다. 그래서 Context 메서드를 다른 클래스에서도 (재)활용할 수 있도록 별도의 클래스로 분리합니다.
public class JdbcContext {
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public void workWithStatementStrategy(StatementStrategy strategy) throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = strategy.makePreparedStatement(c);
ps.executeUpdate();
} catch (SQLException e) {
// 여기서 예외를 던질 필요가 없는지 체크하세요.
throw e;
} finally {
if (ps != null) {
try { // 아래에 있는 연결에 대한 close()를 위해 꼭 필요함
ps.close();
} catch (SQLException ignored) {}
}
if (c != null) {
try {
c.close();
} catch (SQLException ignored) {}
}
}
}
}
public class UserDao {
private JdbcContext jdbcContext;
...
public void add(User user) throws ClassNotFoundException, SQLException {
jdbcContext.workWithStatementStrategy(connection -> {
final 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;
});
}
...
}
add 메서드 내에 PreparedStatement 객체를 생성해 주는 코드 역시 다른 메서드에서 중복될 수 있고 분리한다면 재활용될 수 있는 코드입니다. 생각해보면 매번 달라지는 코드는 SQL 쿼리 문장과 매핑되는 부가 정보 입력입니다.
public class DeleteAllStatement implements StatementStrategy {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement("delete from users");
}
}
그래서 동일하게 변하는 코드와 변하지 않는 코드를 분리해 볼 수 있습니다. PreparedStatement를 생성하는 코드는 JdbcContext 클래스로 분리하고 클라이언트 코드에서는 간결하게 변하는 부분만 파라미터로 전달해 줄 수 있습니다.
public void executeSql(final String sql, String...args) throws SQLException {
workWithStatementStrategy(connection -> {
final PreparedStatement preparedStatement = connection.prepareStatement(sql);
for (int i = 0; i < args.length; i++) {
preparedStatement.setString(i + 1, args[i]);
}
return preparedStatement;
});
}
public void add(User user) throws ClassNotFoundException, SQLException {
jdbcContext.executeSql("insert into users(id, name, password) values(?,?,?)",
user.getId(), user.getName(), user.getPassword());
}
많은 중복 코드들이 재활용될 수 있도록 분리되었습니다. 이제 새로운 메서드를 추가할 때 마다 매번 예외 처리 코드를 신경써 줄 필요가 없습니다. 실수로 한 군데를 잘못 건드리지 않을지 걱정하지 않아도 됩니다. 또한 변하는 것과 변하지 않는 것들이 분리되었습니다. User DB 스키마가 변경어도 JdbcContext 클래스는 전혀 영향을 받지 않습니다. 관심이 다른 클래스 간에는 낮은 결합도를 가지게 되었습니다. 대신 User 테이블이 변경되면 UserDao 클래스만 변경하면 되고 이때 UserDao 전체가 함께 변경됩니다. 이로써 높은 응집성 역시 가지게 되었습니다. 좋네요.