초난감 DAO의 문제점
UserDao를 많이 개선했지만 아직 예외상황에 대한 처리가 안되어 있다는 문제가 있다. UserDao에 예외처리를 할 수 있도록 기능을 추가해보자.
예외처리 기능을 갖춘 DAO
JDBC는 어떤 상황에서도 가져온 리소스를 반환하도록 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) {}
}
}
- 어느 시점에서 예외가 발생했는지에 따라서 예외처리가 달라지지만, try문의 어떤 문장에서 예외처리가 일어났는지 정확히 알 수 없으므로 finally에서는 반드시 c와 ps가 null이 아닌지 먼저 확인한 후에 close() 메소드를 호출해야 한다. 이 때 주의해야 할 점은 close()도 SQLException이 발생할 수 있기 때문에 try/catch문으로 처리해줘야 한다.
JDBC try/catch/finally 코드의 문제점
완성도 높은 DAO 코드가 된 UserDao이지만 복잡한 try/catch/finally 블록이 2중으로 중첩되어 나오고, 모든 메소드에서 반복된다. 이 문제의 핵심은 변하지는 않지만 많은 곳에서 중복되는 코드와 로직에 따라 확장되고 변하는 코드를 분리해내는 작업이다.
UserDao의 메소드를 개선해보자. 가장 먼저 변하는 성격이 다른 것을 찾아내자.
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) {}
}
}
주석 문장은 변하는 부분, 주석을 하지 않은 문장은 변하지 않는 부분이다. 만약 add 메소드라면 변하는 부분으로 표시한 코드를 다음과 같이 바꾸기만 하면 된다.
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());
이를 해결하기 위한 3가지 방법에 대해서 알아보자.
메소드 추출
먼저 생각해볼 수 있는 방법은 변하는 부분을 메소드로 빼내는 것이다.
public void deleteAll() thrwos SQLException {
try {
c = dataSource.getConnection();
ps = makeStatement(c);
ps.executeUpdate();
}catch(SQLException e)
...
}
private PrepareStatement makeStatement (Connection c) throws SQLException {
PreparedStatement ps;
ps = c.preparestatment("delete from users");
return ps;
}
자주 바뀌는 부분을 독립시켰는데 별 이득이 없어 보인다. 메소드 추출 리팩토링을 적용하는 경우에는 분리시킨 메소드를 다른 곳에서 재사용할 수 있어야 하는데, 이건 반대로 분리시키고 남은 메소드가 재사용이 필요한 부분이고, 분리된 메소드는 DAO 로직마다 새롭게 만들어서 확장돼야 하기 때문이다.
템플릿 메소드 패턴
템플릿 메소드 패턴은 상속을 통해 기능을 확장해서 사용하는 부분이다. 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메소드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 한다.
public abstract class UserDao {
protected PreparedStatement makeStatement(Connection c) throws SQLException;
...
}
public class UserDaoDeleteAll extends UserDao {
protected PreparedStatement makeStatement(Connection c) throws SQLException {
PreparedStatement ps = c.preparedStatement("delete from users");
return ps;
}
}
이제 UserDao 클래스의 기능을 확장하고 싶을 때마다 상속을 통해 자유롭게 확장할 수 있고, 확장 때문에 기존의 상위 DAO클래스에 불필요한 변화는 생기지 않도록 할 수 있다. 하지만 템플릿 메소드 패턴으로의 접근은 제한이 많다.
가장 큰 문제는 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다는 점이다. 만약 UserDao의 JDBC 메소드가 4개일 경우 4개의 서브클래스를 만들어서 사용해야 한다. 또 확장구조가 이미 클래스를 설계하는 시점에서 고정되어 버린다.
전략 패턴
개방 폐쇄 원칙을 잘 지키면서도 템플릿 패턴보다 유연하고 확장성이 뛰어난 것이, 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다. 아래의 전략 패턴 구조에서 좌측에 있는 Context의 contextMethod()에서 일정한 구조를 가지고 동작하다가 특정 확장 기능은 Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임한다.
전략 패턴의 구조
deleteAll()의 작동 과정을 정리해보면 다음과 같다.
- DB커넥션 가져오기
- PreparedStatement를 만들어줄 외부 기능 호출하기
- 전달받은 PreparedStatement 실행하기
- 예외가 발생하면 이를 다시 메소드 밖으로 던지기
- 모든 경우에 만들어진 PreparedStatement와 Connection을 적절히 닫아주기
두 번째 작업에서 사용하는 PreparedStatement를 만들어주는 외부 기능이 바로 전략 패턴에서 말하는 전략이라고 볼 수 있다. 이 때 이 PreparedStatement를 생성하는 전략을 호출할 때는 컨텍스트 내에서 만들어둔 DB커넥션을 전달해야 한다. 이를 인터페이스로 정의하면 다음과 같다.
public interface StatementStrategy {
PreparedStatemnt makePreparedStatement(Connection c) throws SQLException;
}
이 인터페이스를 상속해서 실제 전략, 즉 바뀌는 부분인 PreparedStatement를 생성하는 클래스를 만들어보자.
public class DeleteAllStatement implements StatementStrategy {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.preparedStatement("delete from users");
return ps;
}
}
public void deleteAll() throws SQLException {
...
try {
c = dataSource.getConnection();
StatementStrategy strategy = new DeleteAllStatement();
ps = strategy.makePreparedStatement(c);
...
}
}
전략 패턴은 필요한 전략을 클라이언트가 선택하기 때문에 컨텍스트 안에서 이미 구체적인 전략 클래스인 DeleteAllStatemnt를 사용하도록 고정되어 있다. 컨텍스트가 StatementStrategy 인터페이스뿐 아니라 특정 구현 클래스인 DeleteAllStatement를 직접 알고 있다는 건, 전략 패턴에도 OCP에도 잘 들어맞는다고 볼 수 없다.
DI 적용을 위한 클라이언트 / 컨텍스트 분리
전략 패턴에 따르면 Context가 어떤 전략을 사용하게 할 것인가는 Context를 사용하는 앞단의 Client가 결정하는 게 일반적이다. Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에 전달하는 것이다.
UserDao에서 필요한 전략(ConnectionMaker)의 특정 구현 클래스(DConnectionMaker) 오브젝트를 클라이언트(UserDaoTest)가 만들어서 제공해주는 방법을 사용했다. 결국 이 구조에서 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리시킨 것이 바로 ObjectFactory이며, 이를 일반화한 것이 앞에서 살펴봤던 의존관계 주입(DI)였다.
여기서 이 패턴 구조를 코드에 적용해보자. 이 컨텍스트에 해당하는 JDBC try/catch/finally 코드를 클라이언트 코드인 StatementStrategy를 만드는 부분에서 독립시켜야 한다는 점이다.
public void jdbcContextWithStatementStrategy(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) {} }
}
}
public void deleteAll() throws SQLException {
StatementStrategy st = new DeleteAllStatement();
jdbcContextWithStatementStrategy(st);
}
이번에는 이전에 적용했던 전략패턴을 add() 메소드에도 적용해보자. 먼저 add() 메소드에서 변하는 부분인 PreparedStatement를 만드는 코드를 AddStatement 클래스로 옮겨 담는다.
public class AddStatement implements 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;
}
}
이렇게 하면 user에 대한 정보가 없기 때문에 컴파일 오류가 난다. 따라서 이런 정보를 클라이언트가 매개변수로 제공해주어야 한다.
public class AddStatement implements StatementStrategy {
User user;
public AddStatement(User user) { this.user = user; }
}
add() 메소드는 다음과 같이 수정한 뒤 test를 진행하면 성공하는 것을 확인할 수 있다.
public void add(User user) throws ClassNotFoundException, SQLException {
StatementStrategy st = new AddStatement(user);
jdbcContextWithStatementStrategy(st);
}
전략과 클라이언트의 동거
현재 만들어진 구조에 두 가지 불만이 있다.
- DAO 메소드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다. (런타임 시에 다이나믹하게 DI 해준다는 점을 제외하면 템플릿 메소드 패턴을 적용했을 때와 다를게 없다)
- DAO 메소드에서 StatementStrategy에 전달할 User와 같은 부가적인 정보가 있는 경우, 이를 위해 오브젝트를 전달받는 생성자와 이를 저장해둘 인스턴스 변수를 번거롭게 만들어야 한다는 점이다.
로컬 클래스
클래스 파일이 많아지는 문제는 간단한 해결 방법이 있다. StatementStrategy 전략 클래스를 매번 독립된 파일로 만들지 말고 UserDao 클래스 안에 내부 클래스로 정의해버리는 것이다. DeleteAllStatement나 AddStatement는 UserDao 밖에서는 사용되지 않는다. 이렇게 특정 메소드에서만 사용되는 것이라면 다음과 같이 로컬 클래스로 만들 수도 있다.
public void add(User user) throws SQLException {
class AddStatement implements StatementStrategy {
User user;
public AddStatement(User user) { this.user = user; }
@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;
}
}
StatementStrategy st = new AddStatement(user);
jdbcContextWithStatementStrategy(st)
}
로컬 클래스는 선언된 메소드 내에서만 사용할 수 있다. AddStatement가 사용될 곳이 add() 메소드 뿐이라면 이렇게 사용하기 전에 바로 정의해서 쓰는 것도 나쁘지 않다. 덕분에 클래스 파일이 하나 줄어들었고, add() 메소드 안에서 PreparedStatement 생성 로직을 함께 볼 수 있으니 코드를 이해하기도 좋다. 로컬 클래스의 또 다른 장점은 클래스가 내부 클래스이기 때문에 자신이 선언된 곳의 정보에 접근할 수 있다는 점이다.
메소드 파라미터도 일종의 로컬 변수이므로 add() 메소드의 user 변수를 AddStatement에서 직접 사용할 수 있다.
public void add(final User user) throws ClassNotFoundException, SQLException {
class AddStatement implements StatementStrategy {
User user;
public AddStatement(User user) { this.user = user; }
@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;
}
}
StatementStrategy st = new AddStatement(user);
jdbcContextWithStatementStrategy(st);
}
이렇게 내부 클래스를 이용하면 메소드마다 추가해야 했던 클래스 파일을 하나 줄일 수 있고, 내부 클래스의 특징을 이용해 user 매개변수를 두지 않고 로컬 변수를 직접 사용할 수 있다는 장점이 있다.
익명 내부 클래스
한 가지 더 욕심을 내보자. AddStatement 클래스는 add() 메소드에서만 사용할 용도로 만들어졌다. 따라서 좀 더 간결하게 클래스 이름도 제거할 수 있다. AddStatement를 익명 내부 클래스로 만들어보자.
StatementStrategy st = new StatementStrategy() {
public PreaparedStatement 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;
}
}
만들어진 익명 내부 클래스의 오브젝트는 딱 한 번만 사용할 테니 굳이 변수에 담아 두지 말고 jdbcContextWithStatementStrategy() 메소드의 파라미터에서 바로 생성하는 편이 좋다. 이렇게 정리하면 add() 메소드는 다음과 같이 간결해진다.
public void add(final User user) throws SQLException {
jdbcContextWithStatementStrategy(new StatementStrategy() {
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;
}
});
}
delteAll() 메소드도 다음과 같이 간결하게 나타낼 수 있다.
public void deleteAll() throws SQLException {
jdbcContextWithStatementStrategy(new StatementStrategy() {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement("delete from users");
}
});
}
컨텍스트와 DI
전략 패턴의 구조로 보면 UserDao의 메소드가 클라이언트이고, 익명 내부 클래스로 만들어지는 것이 개별적인 전략이며, jdbcContextWithStatementStrategy() 메소드는 컨텍스트다. 이 메소드는 다른 DAO 에서도 사용이 가능하다. 따라서 이를 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 = this.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){}}
}
}
}
UserDao는 JdbcContext에 의존하고 있다. 스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스를 바꿔서 사용하도록 하는 게 목적이다. 하지만 이 경우 JdbcContext는 그 자체로 독립적인 JDBC 컨텍스트를 제공해주는 서비스 오브젝트로서 의미가 있을 뿐이고 구현 방법이 바뀔 가능성이 없다. 따라서 인터페이스로 구현을 하지 않았다.
빈 의존관계 변경
<bean id="userDao" class="com.mango.UserDao">
<property name="dataSource" ref="dataSource" />
<property name="jdbcContext" ref="jdbcContext" />
</bean>
<bean id="jdbcContext" class="dao.JdbcContext">
<property name="dataSource" ref="dataSource" />
</bean>
<bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
<property name="driverClass" value="com.mysql.cj.jdbc.Driver" />
<property name="url" value="jdbc:mysql://localhost/testdb" />
<property name="username" value="root" />
<property name="password" value="1234" />
</bean>
스프링의 DI는 넓게 보면 객체의 생성과 관계 설정에 대한 제어권한을 오브젝트에서 제거하고 외부로 위임했다는 IoC라는 개념을 포괄한다. 인터페이스를 사용해서 클래스를 자유롭게 변경할 수 있게 하지는 않았지만, JdbcContext를 UserDao와 DI구조로 만들어야 할 이유를 알아보자.
1 ) JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되게 때문이다. JDBC 컨텍스트 메소드를 제공해주는 일종의 서비스 오브젝트로서 의미가 있고, 그래서 싱글톤으로 등록돼서 여러 오브젝트에서 공유해 사용되는 것이 이상적이다.
2 ) JdbcContext가 DI를 통해 다른 빈에 의존하고 있다. JdbcContext는 dataSource 프로퍼티를 통해 DataSource 오브젝트를 주입받도록 되어 있는데, DI를 위해서는 주입되는 오브젝트와 주입받는 오브젝트 모두 스프링 빈으로 등록돼야 한다.
여기서 인터페이스의 사용 여부에 대해 다시 생각해보자. 인터페이스가 없다는 건 UserDao와 JdbcContext가 매우 긴밀한 관계를 가지고 강하게 결합되어 있다는 의미이다. UserDao는 항상 JdbcContext클래스와 함께 사용돼야 한다.
코드를 이용하는 수동 DI
JdbcContext를 스프링 빈으로 등록해서 UserDao에 DI하는 대신 사용할 수 있는 방법이 있다. UserDao 내부에서 직접 DI를 적용하는 방법이다. 이 방법은 첫 번째 싱글톤 방법을 포기해야 한다. 하지만 DAO마다 하나의 JdbcContext 오브젝트를 갖고 있게 하는 식으로 타협할 수 있다. 그렇다면 두 번째 이유는 어떤식으로 만족시킬 수 있을까? JdbcContext에 대한 제어권을 갖고 생성과 관리를 담당하는 UserDao에게 DI까지 맡기는 것이다.
(오브젝트를 생성하고 그 의존 오브젝트를 수정자 메소드로 주입해주는 것이 바로 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;
}
}
이 방법의 장점은 인터페이스를 두지 않아도 될 만큼 긴밀한 관계를 갖는 DAO클래스와 JdbcContext를 어색하게 따로 빈으로 분리하지 않고 내부에서 직접 만들어 사용하면서도 다른 오브젝트에 대한 DI를 적용할 수 있따는 점이다.
이렇게 한 오브젝트의 수정자 메소드에서 다른 오브젝트를 초기화하고 코드를 이용해 DI를 하는 것은 스프링에서도 종종 사용되는 기법이다.
스프링의 DI를 이용하기 위해 빈으로 등록해서 사용하는 방법은 오브젝트 사이의 실제 의존관계가 설정파일에 명확하게 드러난다는 장점이 있지만, DI의 근본적인 원칙에 부합하지 않는 구체적인 클래스와의 관계까 설정에 직접 노출된다는 단점이 있다.
반면에 DAO 코드를 이용해 수동으로 DI를 하는 방법은 JdbcContext가 UserDao의 내부에서 만들어지고 사용되면서 그 관계를 외부에는 드러내지 않는다는 장점이 있따. 하지만 JdbcContext를 여러 오브젝트가 사용하더라도 싱글톤으로 만들 수 없고, DI 작업을 위한 부가적인 코드가 필요하다는 단점이 있다.
용어 정리
템플릿
바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법.
전략 패턴
OCP 관점에서 보면 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식.